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,399 @@
1
+ /**
2
+ * PostProcessor.ts - Post-Recording Pipeline Orchestrator
3
+ *
4
+ * Runs the full post-processing pipeline after a recording session stops:
5
+ * 1. Transcribe audio via WhisperService batch mode
6
+ * 2. Analyze transcript to find key moments (heuristic-based)
7
+ * 3. Extract video frames at those timestamps via ffmpeg
8
+ * 4. Return combined result for markdown report generation
9
+ *
10
+ * Designed to degrade gracefully: if frame extraction fails (e.g. ffmpeg
11
+ * not installed), the pipeline still returns a transcript-only result.
12
+ *
13
+ * Part of the post-processing pipeline that runs after recording stops.
14
+ */
15
+
16
+ import { whisperService } from '../transcription/WhisperService';
17
+ import { TranscriptAnalyzer, transcriptAnalyzer } from './TranscriptAnalyzer';
18
+ import { FrameExtractor, frameExtractor } from './FrameExtractor';
19
+ import type { KeyMoment } from './TranscriptAnalyzer';
20
+
21
+ // ============================================================================
22
+ // Types
23
+ // ============================================================================
24
+
25
+ export interface TranscriptSegment {
26
+ text: string;
27
+ startTime: number; // seconds from start of recording
28
+ endTime: number;
29
+ confidence: number;
30
+ }
31
+
32
+ export interface ExtractedFrame {
33
+ path: string; // path to extracted PNG
34
+ timestamp: number; // seconds from start of recording
35
+ reason: string; // why this frame was selected
36
+ transcriptSegment?: TranscriptSegment; // associated transcript segment
37
+ }
38
+
39
+ export interface PostProcessResult {
40
+ transcriptSegments: TranscriptSegment[];
41
+ extractedFrames: ExtractedFrame[];
42
+ reportPath: string;
43
+ }
44
+
45
+ export interface PostProcessProgress {
46
+ step: 'transcribing' | 'analyzing' | 'extracting-frames' | 'generating-report';
47
+ percent: number;
48
+ message: string;
49
+ }
50
+
51
+ export interface PostProcessOptions {
52
+ videoPath: string;
53
+ audioPath: string;
54
+ sessionDir: string;
55
+ transcriptSegments?: TranscriptSegment[];
56
+ aiMomentHints?: KeyMoment[];
57
+ onProgress?: (progress: PostProcessProgress) => void;
58
+ }
59
+
60
+ // ============================================================================
61
+ // PostProcessor Class
62
+ // ============================================================================
63
+
64
+ export class PostProcessor {
65
+ private analyzer: TranscriptAnalyzer;
66
+ private extractor: FrameExtractor;
67
+
68
+ constructor(
69
+ analyzer: TranscriptAnalyzer = transcriptAnalyzer,
70
+ extractor: FrameExtractor = frameExtractor
71
+ ) {
72
+ this.analyzer = analyzer;
73
+ this.extractor = extractor;
74
+ }
75
+
76
+ /**
77
+ * Run the full post-processing pipeline.
78
+ *
79
+ * @param options - Pipeline configuration (paths, progress callback)
80
+ * @returns Combined result with transcript, frames, and report path
81
+ */
82
+ async process(options: PostProcessOptions): Promise<PostProcessResult> {
83
+ const { videoPath, audioPath, sessionDir, transcriptSegments, aiMomentHints, onProgress } = options;
84
+
85
+ const emitProgress = (progress: PostProcessProgress): void => {
86
+ if (onProgress) {
87
+ onProgress(progress);
88
+ }
89
+ };
90
+
91
+ // -----------------------------------------------------------------------
92
+ // Step 1: Transcribe audio (0-40%)
93
+ // -----------------------------------------------------------------------
94
+ emitProgress({
95
+ step: 'transcribing',
96
+ percent: 0,
97
+ message: 'Transcribing audio...',
98
+ });
99
+
100
+ let segments: TranscriptSegment[] = [];
101
+ const providedSegments = (transcriptSegments || [])
102
+ .map((segment) => ({
103
+ text: segment.text?.trim() || '',
104
+ startTime: Number.isFinite(segment.startTime) ? Math.max(0, segment.startTime) : 0,
105
+ endTime: Number.isFinite(segment.endTime)
106
+ ? Math.max(0, segment.endTime)
107
+ : (Number.isFinite(segment.startTime) ? Math.max(0, segment.startTime) + 1.5 : 1.5),
108
+ confidence: Number.isFinite(segment.confidence) ? segment.confidence : 0.8,
109
+ }))
110
+ .filter((segment) => segment.text.length > 0);
111
+
112
+ // Strategy: prefer Whisper file-based transcription when available because
113
+ // it produces higher quality output than the live-streamed segments captured
114
+ // during recording. Fall back to pre-provided segments only when Whisper is
115
+ // unavailable or fails.
116
+ const whisperAvailable = audioPath && whisperService.isModelAvailable();
117
+
118
+ if (whisperAvailable) {
119
+ this.log(
120
+ `Whisper model available, attempting file-based transcription` +
121
+ (providedSegments.length > 0
122
+ ? ` (${providedSegments.length} pre-provided segments available as fallback)`
123
+ : '')
124
+ );
125
+ try {
126
+ emitProgress({
127
+ step: 'transcribing',
128
+ percent: 5,
129
+ message: 'Loading Whisper model...',
130
+ });
131
+
132
+ const whisperResults = await whisperService.transcribeFile(audioPath);
133
+
134
+ segments = whisperResults.map((result) => ({
135
+ text: result.text,
136
+ startTime: result.startTime,
137
+ endTime: result.endTime,
138
+ confidence: result.confidence,
139
+ }));
140
+
141
+ if (segments.length > 0) {
142
+ emitProgress({
143
+ step: 'transcribing',
144
+ percent: 40,
145
+ message: `Whisper transcription complete: ${segments.length} segments`,
146
+ });
147
+ this.log(`Whisper file transcription complete: ${segments.length} segments`);
148
+ } else {
149
+ this.log('Whisper returned 0 segments, will try pre-provided segments as fallback');
150
+ }
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : String(error);
153
+ this.log(`Whisper file transcription failed: ${message}`);
154
+ // Fall through to use pre-provided segments below
155
+ }
156
+ } else {
157
+ this.log(
158
+ `Whisper file transcription skipped: ${!audioPath ? 'no audio file path' : 'model not available'}`
159
+ );
160
+ }
161
+
162
+ // Fall back to pre-provided segments if Whisper did not produce results
163
+ if (segments.length === 0 && providedSegments.length > 0) {
164
+ segments = providedSegments.sort((a, b) => a.startTime - b.startTime);
165
+ emitProgress({
166
+ step: 'transcribing',
167
+ percent: 40,
168
+ message: `Using captured transcript (${segments.length} segments)`,
169
+ });
170
+ this.log(`Using pre-provided transcript segments as fallback: ${segments.length}`);
171
+ }
172
+
173
+ // If neither Whisper nor pre-provided segments produced anything, and we
174
+ // have an audio path but Whisper was not available, attempt Whisper anyway
175
+ // as a last resort (it will throw if the model truly cannot load).
176
+ if (segments.length === 0 && audioPath && !whisperAvailable) {
177
+ try {
178
+ emitProgress({
179
+ step: 'transcribing',
180
+ percent: 5,
181
+ message: 'Attempting transcription...',
182
+ });
183
+
184
+ const whisperResults = await whisperService.transcribeFile(audioPath);
185
+ segments = whisperResults.map((result) => ({
186
+ text: result.text,
187
+ startTime: result.startTime,
188
+ endTime: result.endTime,
189
+ confidence: result.confidence,
190
+ }));
191
+
192
+ if (segments.length > 0) {
193
+ emitProgress({
194
+ step: 'transcribing',
195
+ percent: 40,
196
+ message: `Transcription complete: ${segments.length} segments`,
197
+ });
198
+ this.log(`Whisper last-resort transcription complete: ${segments.length} segments`);
199
+ }
200
+ } catch (error) {
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ this.log(`Whisper last-resort transcription failed: ${message}`);
203
+
204
+ // No segments from any source - return empty result
205
+ return {
206
+ transcriptSegments: [],
207
+ extractedFrames: [],
208
+ reportPath: sessionDir,
209
+ };
210
+ }
211
+ }
212
+
213
+ if (segments.length === 0) {
214
+ this.log('No transcript segments found, returning empty result');
215
+ emitProgress({
216
+ step: 'generating-report',
217
+ percent: 100,
218
+ message: 'No speech detected in recording',
219
+ });
220
+
221
+ return {
222
+ transcriptSegments: [],
223
+ extractedFrames: [],
224
+ reportPath: sessionDir,
225
+ };
226
+ }
227
+
228
+ // -----------------------------------------------------------------------
229
+ // Step 2: Analyze transcript for key moments (40-50%)
230
+ // -----------------------------------------------------------------------
231
+ emitProgress({
232
+ step: 'analyzing',
233
+ percent: 40,
234
+ message: 'Analyzing transcript for key moments...',
235
+ });
236
+
237
+ const keyMoments = this.analyzer.analyze(segments, aiMomentHints || []);
238
+
239
+ emitProgress({
240
+ step: 'analyzing',
241
+ percent: 50,
242
+ message: `Found ${keyMoments.length} key moments`,
243
+ });
244
+
245
+ this.log(`Analysis complete: ${keyMoments.length} key moments identified`);
246
+
247
+ // -----------------------------------------------------------------------
248
+ // Step 3: Extract frames from video (50-90%)
249
+ // -----------------------------------------------------------------------
250
+ emitProgress({
251
+ step: 'extracting-frames',
252
+ percent: 50,
253
+ message: 'Extracting video frames...',
254
+ });
255
+
256
+ let extractedFrames: ExtractedFrame[] = [];
257
+
258
+ try {
259
+ const timestamps = keyMoments.map((m) => m.timestamp);
260
+
261
+ const extractionResult = await this.extractor.extract({
262
+ videoPath,
263
+ timestamps,
264
+ outputDir: sessionDir,
265
+ });
266
+
267
+ if (!extractionResult.ffmpegAvailable) {
268
+ this.log('ffmpeg not available, skipping frame extraction');
269
+ emitProgress({
270
+ step: 'extracting-frames',
271
+ percent: 90,
272
+ message: 'Frame extraction skipped (ffmpeg not installed)',
273
+ });
274
+ } else {
275
+ // Map extraction results to ExtractedFrame with associated transcript segments
276
+ extractedFrames = extractionResult.frames
277
+ .filter((f) => f.success)
278
+ .map((frame) => {
279
+ // Find the key moment that corresponds to this frame
280
+ const moment = keyMoments.find(
281
+ (m) => Math.abs(m.timestamp - frame.timestamp) < 0.5
282
+ );
283
+
284
+ // Find the closest transcript segment
285
+ const closestSegment = this.findClosestSegment(
286
+ frame.timestamp,
287
+ segments
288
+ );
289
+
290
+ return {
291
+ path: frame.path,
292
+ timestamp: frame.timestamp,
293
+ reason: moment?.reason ?? 'Extracted frame',
294
+ transcriptSegment: closestSegment,
295
+ };
296
+ });
297
+
298
+ emitProgress({
299
+ step: 'extracting-frames',
300
+ percent: 90,
301
+ message: `Extracted ${extractedFrames.length} frames`,
302
+ });
303
+
304
+ this.log(`Frame extraction complete: ${extractedFrames.length} frames`);
305
+ }
306
+ } catch (error) {
307
+ const message = error instanceof Error ? error.message : String(error);
308
+ this.log(`Frame extraction failed: ${message} - continuing with transcript only`);
309
+
310
+ emitProgress({
311
+ step: 'extracting-frames',
312
+ percent: 90,
313
+ message: 'Frame extraction failed, continuing with transcript only',
314
+ });
315
+
316
+ // Continue with transcript-only result - do not throw
317
+ }
318
+
319
+ // -----------------------------------------------------------------------
320
+ // Step 4: Return result (90-100%)
321
+ // -----------------------------------------------------------------------
322
+ emitProgress({
323
+ step: 'generating-report',
324
+ percent: 90,
325
+ message: 'Preparing results...',
326
+ });
327
+
328
+ const result: PostProcessResult = {
329
+ transcriptSegments: segments,
330
+ extractedFrames,
331
+ reportPath: sessionDir,
332
+ };
333
+
334
+ emitProgress({
335
+ step: 'generating-report',
336
+ percent: 100,
337
+ message: 'Post-processing complete',
338
+ });
339
+
340
+ this.log(
341
+ `Pipeline complete: ${segments.length} segments, ${extractedFrames.length} frames`
342
+ );
343
+
344
+ return result;
345
+ }
346
+
347
+ // ============================================================================
348
+ // Private Methods
349
+ // ============================================================================
350
+
351
+ /**
352
+ * Find the transcript segment closest to a given timestamp.
353
+ * Prefers segments that contain the timestamp; falls back to nearest start time.
354
+ */
355
+ private findClosestSegment(
356
+ timestamp: number,
357
+ segments: TranscriptSegment[]
358
+ ): TranscriptSegment | undefined {
359
+ if (segments.length === 0) {
360
+ return undefined;
361
+ }
362
+
363
+ // First, check if timestamp falls within any segment
364
+ for (const segment of segments) {
365
+ if (timestamp >= segment.startTime && timestamp <= segment.endTime) {
366
+ return segment;
367
+ }
368
+ }
369
+
370
+ // Otherwise, find the closest segment by start time
371
+ let closest = segments[0];
372
+ let minDistance = Math.abs(timestamp - closest.startTime);
373
+
374
+ for (let i = 1; i < segments.length; i++) {
375
+ const distance = Math.abs(timestamp - segments[i].startTime);
376
+ if (distance < minDistance) {
377
+ minDistance = distance;
378
+ closest = segments[i];
379
+ }
380
+ }
381
+
382
+ return closest;
383
+ }
384
+
385
+ /**
386
+ * Log helper with consistent prefix.
387
+ */
388
+ private log(message: string): void {
389
+ const timestamp = new Date().toISOString();
390
+ console.log(`[PostProcessor ${timestamp}] ${message}`);
391
+ }
392
+ }
393
+
394
+ // ============================================================================
395
+ // Singleton Export
396
+ // ============================================================================
397
+
398
+ export const postProcessor = new PostProcessor();
399
+ export default PostProcessor;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * TranscriptAnalyzer.ts - Heuristic Key Moment Detection
3
+ *
4
+ * Analyzes transcript segments to identify moments where video frames
5
+ * should be extracted. Uses local heuristics (no AI required):
6
+ *
7
+ * - Natural pauses > 1.5s between segments
8
+ * - Periodic baseline captures every 15-20 seconds
9
+ * - Session start and end always included
10
+ *
11
+ * Part of the post-processing pipeline that runs after recording stops.
12
+ */
13
+
14
+ import type { TranscriptSegment } from './PostProcessor';
15
+
16
+ // ============================================================================
17
+ // Types
18
+ // ============================================================================
19
+
20
+ export interface KeyMoment {
21
+ timestamp: number; // seconds from start of recording
22
+ reason: string; // human-readable reason for selection
23
+ confidence: number; // 0-1
24
+ }
25
+
26
+ // ============================================================================
27
+ // Constants
28
+ // ============================================================================
29
+
30
+ /** Minimum gap between segments to count as a natural pause */
31
+ const PAUSE_THRESHOLD_SECONDS = 1.5;
32
+
33
+ /** Baseline periodic capture interval when no pauses are found */
34
+ const PERIODIC_INTERVAL_SECONDS = 15;
35
+
36
+ /** Maximum periodic interval to avoid sparse captures */
37
+ const MAX_PERIODIC_INTERVAL_SECONDS = 20;
38
+
39
+ /** Hard cap on returned key moments */
40
+ const MAX_KEY_MOMENTS = 20;
41
+
42
+ // ============================================================================
43
+ // TranscriptAnalyzer Class
44
+ // ============================================================================
45
+
46
+ export class TranscriptAnalyzer {
47
+ /**
48
+ * Analyze transcript segments and return key moments where frames
49
+ * should be extracted from the video recording.
50
+ *
51
+ * @param segments - Array of transcript segments with timing info
52
+ * @param aiHints - Optional AI-informed key-moment hints to merge
53
+ * @returns Array of key moments sorted by timestamp, capped at 20
54
+ */
55
+ analyze(segments: TranscriptSegment[], aiHints: KeyMoment[] = []): KeyMoment[] {
56
+ if (segments.length === 0) {
57
+ return [];
58
+ }
59
+
60
+ const moments: KeyMoment[] = [];
61
+
62
+ // Always include session start
63
+ const firstSegment = segments[0];
64
+ moments.push({
65
+ timestamp: firstSegment.startTime,
66
+ reason: 'Session start',
67
+ confidence: 1.0,
68
+ });
69
+
70
+ // Detect natural pauses between segments
71
+ for (let i = 1; i < segments.length; i++) {
72
+ const prev = segments[i - 1];
73
+ const curr = segments[i];
74
+ const gap = curr.startTime - prev.endTime;
75
+
76
+ if (gap >= PAUSE_THRESHOLD_SECONDS) {
77
+ // Place the key moment at the start of the pause (end of previous segment)
78
+ moments.push({
79
+ timestamp: prev.endTime,
80
+ reason: 'Natural pause in narration',
81
+ confidence: Math.min(1.0, gap / 3.0), // Longer pauses = higher confidence
82
+ });
83
+ }
84
+ }
85
+
86
+ // Always include session end
87
+ const lastSegment = segments[segments.length - 1];
88
+ if (lastSegment.endTime !== firstSegment.startTime) {
89
+ moments.push({
90
+ timestamp: lastSegment.endTime,
91
+ reason: 'Session end',
92
+ confidence: 1.0,
93
+ });
94
+ }
95
+
96
+ // If we found fewer than 3 moments (just start/end), add periodic baseline captures.
97
+ // When AI hints exist, prefer them over periodic filler captures.
98
+ if (moments.length < 3 && aiHints.length === 0) {
99
+ const sessionStart = firstSegment.startTime;
100
+ const sessionEnd = lastSegment.endTime;
101
+ const sessionDuration = sessionEnd - sessionStart;
102
+
103
+ if (sessionDuration > PERIODIC_INTERVAL_SECONDS) {
104
+ // Calculate interval: target 15s but stretch up to 20s to avoid one extra capture
105
+ const rawCount = Math.floor(sessionDuration / PERIODIC_INTERVAL_SECONDS);
106
+ const interval = Math.min(
107
+ sessionDuration / rawCount,
108
+ MAX_PERIODIC_INTERVAL_SECONDS
109
+ );
110
+
111
+ for (
112
+ let t = sessionStart + interval;
113
+ t < sessionEnd;
114
+ t += interval
115
+ ) {
116
+ moments.push({
117
+ timestamp: t,
118
+ reason: 'Periodic capture',
119
+ confidence: 0.5,
120
+ });
121
+ }
122
+ }
123
+ }
124
+
125
+ // Merge AI hints (when available) so frame extraction can prioritize
126
+ // narration moments that the analysis pipeline considered important.
127
+ for (const hint of aiHints) {
128
+ if (!Number.isFinite(hint.timestamp)) {
129
+ continue;
130
+ }
131
+ moments.push({
132
+ timestamp: Math.max(0, hint.timestamp),
133
+ reason: hint.reason?.trim() || 'AI-highlighted context',
134
+ confidence: Math.max(0, Math.min(1, Number.isFinite(hint.confidence) ? hint.confidence : 0.8)),
135
+ });
136
+ }
137
+
138
+ // Deduplicate moments that are very close together (within 1 second)
139
+ const deduped = this.deduplicateMoments(moments);
140
+
141
+ // Sort by timestamp
142
+ deduped.sort((a, b) => a.timestamp - b.timestamp);
143
+
144
+ // Cap at MAX_KEY_MOMENTS, keeping highest confidence ones
145
+ if (deduped.length > MAX_KEY_MOMENTS) {
146
+ // Always keep first and last; rank the rest by confidence
147
+ const first = deduped[0];
148
+ const last = deduped[deduped.length - 1];
149
+ const middle = deduped
150
+ .slice(1, -1)
151
+ .sort((a, b) => {
152
+ const priorityDelta = this.momentPriority(b) - this.momentPriority(a);
153
+ if (priorityDelta !== 0) {
154
+ return priorityDelta;
155
+ }
156
+ return b.confidence - a.confidence;
157
+ })
158
+ .slice(0, MAX_KEY_MOMENTS - 2);
159
+
160
+ const capped = [first, ...middle, last];
161
+ capped.sort((a, b) => a.timestamp - b.timestamp);
162
+ return capped;
163
+ }
164
+
165
+ return deduped;
166
+ }
167
+
168
+ /**
169
+ * Remove moments that are within 1 second of each other,
170
+ * keeping the one with higher confidence.
171
+ */
172
+ private deduplicateMoments(moments: KeyMoment[]): KeyMoment[] {
173
+ if (moments.length <= 1) {
174
+ return moments;
175
+ }
176
+
177
+ // Sort by timestamp first for grouping
178
+ const sorted = [...moments].sort((a, b) => a.timestamp - b.timestamp);
179
+ const result: KeyMoment[] = [sorted[0]];
180
+
181
+ for (let i = 1; i < sorted.length; i++) {
182
+ const prev = result[result.length - 1];
183
+ const curr = sorted[i];
184
+
185
+ if (curr.timestamp - prev.timestamp < 1.0) {
186
+ // Prefer higher-priority moments (AI / semantic captures over periodic).
187
+ const currPriority = this.momentPriority(curr);
188
+ const prevPriority = this.momentPriority(prev);
189
+ if (
190
+ currPriority > prevPriority ||
191
+ (currPriority === prevPriority && curr.confidence > prev.confidence)
192
+ ) {
193
+ result[result.length - 1] = curr;
194
+ }
195
+ } else {
196
+ result.push(curr);
197
+ }
198
+ }
199
+
200
+ return result;
201
+ }
202
+
203
+ private momentPriority(moment: KeyMoment): number {
204
+ const reason = (moment.reason || '').toLowerCase();
205
+ if (reason.includes('session start') || reason.includes('session end')) {
206
+ return 4;
207
+ }
208
+ if (reason.includes('ai-') || reason.includes('ai ')) {
209
+ return 3;
210
+ }
211
+ if (reason.includes('natural pause')) {
212
+ return 2;
213
+ }
214
+ if (reason.includes('periodic')) {
215
+ return 0;
216
+ }
217
+ return 1;
218
+ }
219
+ }
220
+
221
+ // ============================================================================
222
+ // Singleton Export
223
+ // ============================================================================
224
+
225
+ export const transcriptAnalyzer = new TranscriptAnalyzer();
226
+ export default TranscriptAnalyzer;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Pipeline Module - Post-Processing Pipeline
3
+ *
4
+ * After recording stops, this pipeline:
5
+ * 1. Transcribes audio via WhisperService (batch mode)
6
+ * 2. Analyzes transcript for key moments (heuristic-based)
7
+ * 3. Extracts video frames at those timestamps (via ffmpeg)
8
+ * 4. Returns structured data for markdown report generation
9
+ */
10
+
11
+ // ============================================================================
12
+ // Classes & Singletons
13
+ // ============================================================================
14
+
15
+ export { PostProcessor, postProcessor } from './PostProcessor';
16
+ export { FrameExtractor, frameExtractor } from './FrameExtractor';
17
+ export { TranscriptAnalyzer, transcriptAnalyzer } from './TranscriptAnalyzer';
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export type {
24
+ PostProcessResult,
25
+ TranscriptSegment,
26
+ ExtractedFrame,
27
+ PostProcessProgress,
28
+ PostProcessOptions,
29
+ } from './PostProcessor';
30
+
31
+ export type {
32
+ FrameExtractionRequest,
33
+ FrameExtractionResult,
34
+ } from './FrameExtractor';
35
+
36
+ export type { KeyMoment } from './TranscriptAnalyzer';