markupr 2.1.8 → 2.4.0

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 (300) hide show
  1. package/README.md +196 -15
  2. package/dist/cli/index.mjs +2000 -0
  3. package/dist/main/index.mjs +743 -220
  4. package/dist/mcp/index.mjs +2719 -0
  5. package/package.json +21 -6
  6. package/.claude/commands/review-feedback.md +0 -47
  7. package/.eslintrc.json +0 -35
  8. package/.github/CODEOWNERS +0 -16
  9. package/.github/FUNDING.yml +0 -1
  10. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -56
  11. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -54
  12. package/.github/PULL_REQUEST_TEMPLATE.md +0 -89
  13. package/.github/dependabot.yml +0 -70
  14. package/.github/workflows/ci.yml +0 -184
  15. package/.github/workflows/deploy-landing.yml +0 -134
  16. package/.github/workflows/nightly.yml +0 -288
  17. package/.github/workflows/release.yml +0 -318
  18. package/CHANGELOG.md +0 -127
  19. package/CLAUDE.md +0 -137
  20. package/CODE_OF_CONDUCT.md +0 -9
  21. package/CONTRIBUTING.md +0 -390
  22. package/PRODUCT_VISION.md +0 -277
  23. package/SECURITY.md +0 -51
  24. package/SIGNING_INSTRUCTIONS.md +0 -284
  25. package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +0 -130
  26. package/assets/svg-source/dmg-background.svg +0 -70
  27. package/assets/svg-source/icon.svg +0 -20
  28. package/assets/svg-source/tray-icon-processing.svg +0 -7
  29. package/assets/svg-source/tray-icon-recording.svg +0 -7
  30. package/assets/svg-source/tray-icon.svg +0 -6
  31. package/assets/tray-complete.png +0 -0
  32. package/assets/tray-complete@2x.png +0 -0
  33. package/assets/tray-completeTemplate.png +0 -0
  34. package/assets/tray-completeTemplate@2x.png +0 -0
  35. package/assets/tray-error.png +0 -0
  36. package/assets/tray-error@2x.png +0 -0
  37. package/assets/tray-errorTemplate.png +0 -0
  38. package/assets/tray-errorTemplate@2x.png +0 -0
  39. package/assets/tray-icon-processing.png +0 -0
  40. package/assets/tray-icon-processing@2x.png +0 -0
  41. package/assets/tray-icon-processingTemplate.png +0 -0
  42. package/assets/tray-icon-processingTemplate@2x.png +0 -0
  43. package/assets/tray-icon-recording.png +0 -0
  44. package/assets/tray-icon-recording@2x.png +0 -0
  45. package/assets/tray-icon-recordingTemplate.png +0 -0
  46. package/assets/tray-icon-recordingTemplate@2x.png +0 -0
  47. package/assets/tray-icon.png +0 -0
  48. package/assets/tray-icon@2x.png +0 -0
  49. package/assets/tray-iconTemplate.png +0 -0
  50. package/assets/tray-iconTemplate@2x.png +0 -0
  51. package/assets/tray-idle.png +0 -0
  52. package/assets/tray-idle@2x.png +0 -0
  53. package/assets/tray-idleTemplate.png +0 -0
  54. package/assets/tray-idleTemplate@2x.png +0 -0
  55. package/assets/tray-processing-0.png +0 -0
  56. package/assets/tray-processing-0@2x.png +0 -0
  57. package/assets/tray-processing-0Template.png +0 -0
  58. package/assets/tray-processing-0Template@2x.png +0 -0
  59. package/assets/tray-processing-1.png +0 -0
  60. package/assets/tray-processing-1@2x.png +0 -0
  61. package/assets/tray-processing-1Template.png +0 -0
  62. package/assets/tray-processing-1Template@2x.png +0 -0
  63. package/assets/tray-processing-2.png +0 -0
  64. package/assets/tray-processing-2@2x.png +0 -0
  65. package/assets/tray-processing-2Template.png +0 -0
  66. package/assets/tray-processing-2Template@2x.png +0 -0
  67. package/assets/tray-processing-3.png +0 -0
  68. package/assets/tray-processing-3@2x.png +0 -0
  69. package/assets/tray-processing-3Template.png +0 -0
  70. package/assets/tray-processing-3Template@2x.png +0 -0
  71. package/assets/tray-processing.png +0 -0
  72. package/assets/tray-processing@2x.png +0 -0
  73. package/assets/tray-processingTemplate.png +0 -0
  74. package/assets/tray-processingTemplate@2x.png +0 -0
  75. package/assets/tray-recording.png +0 -0
  76. package/assets/tray-recording@2x.png +0 -0
  77. package/assets/tray-recordingTemplate.png +0 -0
  78. package/assets/tray-recordingTemplate@2x.png +0 -0
  79. package/build/DMG_BACKGROUND_SPEC.md +0 -50
  80. package/build/dmg-background.png +0 -0
  81. package/build/dmg-background@2x.png +0 -0
  82. package/build/entitlements.mac.inherit.plist +0 -27
  83. package/build/entitlements.mac.plist +0 -41
  84. package/build/favicon-16.png +0 -0
  85. package/build/favicon-180.png +0 -0
  86. package/build/favicon-192.png +0 -0
  87. package/build/favicon-32.png +0 -0
  88. package/build/favicon-48.png +0 -0
  89. package/build/favicon-512.png +0 -0
  90. package/build/favicon-64.png +0 -0
  91. package/build/icon-128.png +0 -0
  92. package/build/icon-16.png +0 -0
  93. package/build/icon-24.png +0 -0
  94. package/build/icon-256.png +0 -0
  95. package/build/icon-32.png +0 -0
  96. package/build/icon-48.png +0 -0
  97. package/build/icon-64.png +0 -0
  98. package/build/icon.icns +0 -0
  99. package/build/icon.ico +0 -0
  100. package/build/icon.iconset/icon_128x128.png +0 -0
  101. package/build/icon.iconset/icon_128x128@2x.png +0 -0
  102. package/build/icon.iconset/icon_16x16.png +0 -0
  103. package/build/icon.iconset/icon_16x16@2x.png +0 -0
  104. package/build/icon.iconset/icon_256x256.png +0 -0
  105. package/build/icon.iconset/icon_256x256@2x.png +0 -0
  106. package/build/icon.iconset/icon_32x32.png +0 -0
  107. package/build/icon.iconset/icon_32x32@2x.png +0 -0
  108. package/build/icon.iconset/icon_512x512.png +0 -0
  109. package/build/icon.iconset/icon_512x512@2x.png +0 -0
  110. package/build/icon.png +0 -0
  111. package/build/installer-header.bmp +0 -0
  112. package/build/installer-header.png +0 -0
  113. package/build/installer-sidebar.bmp +0 -0
  114. package/build/installer-sidebar.png +0 -0
  115. package/build/installer.nsh +0 -45
  116. package/build/overlay-processing.png +0 -0
  117. package/build/overlay-recording.png +0 -0
  118. package/build/toolbar-record.png +0 -0
  119. package/build/toolbar-screenshot.png +0 -0
  120. package/build/toolbar-settings.png +0 -0
  121. package/build/toolbar-stop.png +0 -0
  122. package/dist/preload/index.mjs +0 -907
  123. package/dist/renderer/assets/index-CCmUjl9K.js +0 -19495
  124. package/dist/renderer/assets/index-CUqz_Gs6.css +0 -2270
  125. package/dist/renderer/index.html +0 -27
  126. package/docs/AI_AGENT_QUICKSTART.md +0 -42
  127. package/docs/AI_PIPELINE_DESIGN.md +0 -595
  128. package/docs/API.md +0 -514
  129. package/docs/ARCHITECTURE.md +0 -460
  130. package/docs/CONFIGURATION.md +0 -336
  131. package/docs/DEVELOPMENT.md +0 -508
  132. package/docs/EXPORT_FORMATS.md +0 -451
  133. package/docs/GETTING_STARTED.md +0 -236
  134. package/docs/KEYBOARD_SHORTCUTS.md +0 -334
  135. package/docs/TROUBLESHOOTING.md +0 -418
  136. package/docs/landing/index.html +0 -672
  137. package/docs/landing/script.js +0 -342
  138. package/docs/landing/styles.css +0 -1543
  139. package/electron-builder.yml +0 -140
  140. package/electron.vite.config.ts +0 -63
  141. package/railway.json +0 -12
  142. package/scripts/build.mjs +0 -51
  143. package/scripts/generate-icons.mjs +0 -314
  144. package/scripts/generate-installer-images.cjs +0 -253
  145. package/scripts/generate-tray-icons.mjs +0 -258
  146. package/scripts/notarize.cjs +0 -180
  147. package/scripts/one-click-clean-test.sh +0 -147
  148. package/scripts/postinstall.mjs +0 -36
  149. package/scripts/setup-markupr.sh +0 -55
  150. package/setup +0 -17
  151. package/site/index.html +0 -1835
  152. package/site/package.json +0 -11
  153. package/site/railway.json +0 -12
  154. package/site/server.js +0 -31
  155. package/src/main/AutoUpdater.ts +0 -392
  156. package/src/main/CrashRecovery.ts +0 -655
  157. package/src/main/ErrorHandler.ts +0 -703
  158. package/src/main/HotkeyManager.ts +0 -399
  159. package/src/main/MenuManager.ts +0 -529
  160. package/src/main/PermissionManager.ts +0 -420
  161. package/src/main/SessionController.ts +0 -1465
  162. package/src/main/TrayManager.ts +0 -540
  163. package/src/main/ai/AIPipelineManager.ts +0 -199
  164. package/src/main/ai/ClaudeAnalyzer.ts +0 -339
  165. package/src/main/ai/ImageOptimizer.ts +0 -176
  166. package/src/main/ai/StructuredMarkdownBuilder.ts +0 -379
  167. package/src/main/ai/index.ts +0 -16
  168. package/src/main/ai/types.ts +0 -258
  169. package/src/main/analysis/ClarificationGenerator.ts +0 -385
  170. package/src/main/analysis/FeedbackAnalyzer.ts +0 -531
  171. package/src/main/analysis/index.ts +0 -19
  172. package/src/main/audio/AudioCapture.ts +0 -978
  173. package/src/main/audio/audioUtils.ts +0 -100
  174. package/src/main/audio/index.ts +0 -20
  175. package/src/main/capture/index.ts +0 -1
  176. package/src/main/index.ts +0 -1693
  177. package/src/main/ipc/captureHandlers.ts +0 -272
  178. package/src/main/ipc/index.ts +0 -45
  179. package/src/main/ipc/outputHandlers.ts +0 -302
  180. package/src/main/ipc/sessionHandlers.ts +0 -56
  181. package/src/main/ipc/settingsHandlers.ts +0 -471
  182. package/src/main/ipc/types.ts +0 -56
  183. package/src/main/ipc/windowHandlers.ts +0 -277
  184. package/src/main/output/ClipboardService.ts +0 -369
  185. package/src/main/output/ExportService.ts +0 -539
  186. package/src/main/output/FileManager.ts +0 -416
  187. package/src/main/output/MarkdownGenerator.ts +0 -791
  188. package/src/main/output/MarkdownPatcher.ts +0 -299
  189. package/src/main/output/index.ts +0 -186
  190. package/src/main/output/sessionAdapter.ts +0 -207
  191. package/src/main/output/templates/html-template.ts +0 -553
  192. package/src/main/pipeline/FrameExtractor.ts +0 -330
  193. package/src/main/pipeline/PostProcessor.ts +0 -399
  194. package/src/main/pipeline/TranscriptAnalyzer.ts +0 -226
  195. package/src/main/pipeline/index.ts +0 -36
  196. package/src/main/platform/WindowsTaskbar.ts +0 -600
  197. package/src/main/platform/index.ts +0 -16
  198. package/src/main/settings/SettingsManager.ts +0 -730
  199. package/src/main/settings/index.ts +0 -19
  200. package/src/main/transcription/ModelDownloadManager.ts +0 -494
  201. package/src/main/transcription/TierManager.ts +0 -219
  202. package/src/main/transcription/TranscriptionRecoveryService.ts +0 -340
  203. package/src/main/transcription/WhisperService.ts +0 -748
  204. package/src/main/transcription/index.ts +0 -56
  205. package/src/main/transcription/types.ts +0 -135
  206. package/src/main/windows/PopoverManager.ts +0 -284
  207. package/src/main/windows/TaskbarIntegration.ts +0 -452
  208. package/src/main/windows/index.ts +0 -23
  209. package/src/preload/index.ts +0 -1047
  210. package/src/renderer/App.tsx +0 -515
  211. package/src/renderer/AppWrapper.tsx +0 -28
  212. package/src/renderer/assets/logo-dark.svg +0 -7
  213. package/src/renderer/assets/logo.svg +0 -7
  214. package/src/renderer/audio/AudioCaptureRenderer.ts +0 -454
  215. package/src/renderer/capture/ScreenRecordingRenderer.ts +0 -492
  216. package/src/renderer/components/AnnotationOverlay.tsx +0 -836
  217. package/src/renderer/components/AudioWaveform.tsx +0 -811
  218. package/src/renderer/components/ClarificationQuestions.tsx +0 -656
  219. package/src/renderer/components/CountdownTimer.tsx +0 -495
  220. package/src/renderer/components/CrashRecoveryDialog.tsx +0 -632
  221. package/src/renderer/components/DonateButton.tsx +0 -127
  222. package/src/renderer/components/ErrorBoundary.tsx +0 -308
  223. package/src/renderer/components/ExportDialog.tsx +0 -872
  224. package/src/renderer/components/HotkeyHint.tsx +0 -261
  225. package/src/renderer/components/KeyboardShortcuts.tsx +0 -787
  226. package/src/renderer/components/ModelDownloadDialog.tsx +0 -844
  227. package/src/renderer/components/Onboarding.tsx +0 -1830
  228. package/src/renderer/components/ProcessingOverlay.tsx +0 -157
  229. package/src/renderer/components/RecordingOverlay.tsx +0 -423
  230. package/src/renderer/components/SessionHistory.tsx +0 -1746
  231. package/src/renderer/components/SessionReview.tsx +0 -1321
  232. package/src/renderer/components/SettingsPanel.tsx +0 -217
  233. package/src/renderer/components/Skeleton.tsx +0 -347
  234. package/src/renderer/components/StatusIndicator.tsx +0 -86
  235. package/src/renderer/components/ThemeProvider.tsx +0 -429
  236. package/src/renderer/components/Tooltip.tsx +0 -370
  237. package/src/renderer/components/TranscriptionPreview.tsx +0 -183
  238. package/src/renderer/components/TranscriptionTierSelector.tsx +0 -640
  239. package/src/renderer/components/UpdateNotification.tsx +0 -377
  240. package/src/renderer/components/WindowSelector.tsx +0 -947
  241. package/src/renderer/components/index.ts +0 -99
  242. package/src/renderer/components/primitives/ApiKeyInput.tsx +0 -98
  243. package/src/renderer/components/primitives/ColorPicker.tsx +0 -65
  244. package/src/renderer/components/primitives/DangerButton.tsx +0 -45
  245. package/src/renderer/components/primitives/DirectoryPicker.tsx +0 -41
  246. package/src/renderer/components/primitives/Dropdown.tsx +0 -34
  247. package/src/renderer/components/primitives/KeyRecorder.tsx +0 -117
  248. package/src/renderer/components/primitives/SettingsSection.tsx +0 -32
  249. package/src/renderer/components/primitives/Slider.tsx +0 -43
  250. package/src/renderer/components/primitives/Toggle.tsx +0 -36
  251. package/src/renderer/components/primitives/index.ts +0 -10
  252. package/src/renderer/components/settings/AdvancedTab.tsx +0 -174
  253. package/src/renderer/components/settings/AppearanceTab.tsx +0 -77
  254. package/src/renderer/components/settings/GeneralTab.tsx +0 -40
  255. package/src/renderer/components/settings/HotkeysTab.tsx +0 -79
  256. package/src/renderer/components/settings/RecordingTab.tsx +0 -84
  257. package/src/renderer/components/settings/index.ts +0 -9
  258. package/src/renderer/components/settings/settingsStyles.ts +0 -673
  259. package/src/renderer/components/settings/tabConfig.tsx +0 -85
  260. package/src/renderer/components/settings/useSettingsPanel.ts +0 -447
  261. package/src/renderer/contexts/ProcessingContext.tsx +0 -227
  262. package/src/renderer/contexts/RecordingContext.tsx +0 -683
  263. package/src/renderer/contexts/UIContext.tsx +0 -326
  264. package/src/renderer/contexts/index.ts +0 -24
  265. package/src/renderer/donateMessages.ts +0 -69
  266. package/src/renderer/hooks/index.ts +0 -75
  267. package/src/renderer/hooks/useAnimation.tsx +0 -544
  268. package/src/renderer/hooks/useTheme.ts +0 -313
  269. package/src/renderer/index.html +0 -26
  270. package/src/renderer/main.tsx +0 -52
  271. package/src/renderer/styles/animations.css +0 -1093
  272. package/src/renderer/styles/app-shell.css +0 -662
  273. package/src/renderer/styles/globals.css +0 -515
  274. package/src/renderer/styles/theme.ts +0 -578
  275. package/src/renderer/types/electron.d.ts +0 -385
  276. package/src/shared/hotkeys.ts +0 -283
  277. package/src/shared/types.ts +0 -809
  278. package/tests/clipboard.test.ts +0 -228
  279. package/tests/e2e/criticalPaths.test.ts +0 -594
  280. package/tests/feedbackAnalyzer.test.ts +0 -303
  281. package/tests/integration/sessionFlow.test.ts +0 -583
  282. package/tests/markdownGenerator.test.ts +0 -418
  283. package/tests/output.test.ts +0 -96
  284. package/tests/setup.ts +0 -486
  285. package/tests/unit/appIntegration.test.ts +0 -676
  286. package/tests/unit/appViewState.test.ts +0 -281
  287. package/tests/unit/audioIpcChannels.test.ts +0 -17
  288. package/tests/unit/exportService.test.ts +0 -492
  289. package/tests/unit/hotkeys.test.ts +0 -92
  290. package/tests/unit/navigationPreload.test.ts +0 -94
  291. package/tests/unit/onboardingFlow.test.ts +0 -345
  292. package/tests/unit/permissionManager.test.ts +0 -175
  293. package/tests/unit/permissionManagerExpanded.test.ts +0 -296
  294. package/tests/unit/screenRecordingRenderer.test.ts +0 -368
  295. package/tests/unit/sessionController.test.ts +0 -515
  296. package/tests/unit/tierManager.test.ts +0 -61
  297. package/tests/unit/tierManagerExpanded.test.ts +0 -142
  298. package/tests/unit/transcriptAnalyzer.test.ts +0 -64
  299. package/tsconfig.json +0 -25
  300. package/vitest.config.ts +0 -46
@@ -0,0 +1,2000 @@
1
+ #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/cli/index.ts
10
+ import { existsSync as existsSync4 } from "fs";
11
+ import { resolve } from "path";
12
+ import { Command } from "commander";
13
+
14
+ // src/cli/CLIPipeline.ts
15
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
16
+ import { stat, unlink as unlink2, writeFile, chmod as chmod2 } from "fs/promises";
17
+ import { join as join3, basename as basename2 } from "path";
18
+ import { execFile as execFileCb2 } from "child_process";
19
+ import { tmpdir as tmpdir2 } from "os";
20
+ import { randomUUID as randomUUID2 } from "crypto";
21
+
22
+ // src/main/pipeline/TranscriptAnalyzer.ts
23
+ var PAUSE_THRESHOLD_SECONDS = 1.5;
24
+ var PERIODIC_INTERVAL_SECONDS = 15;
25
+ var MAX_PERIODIC_INTERVAL_SECONDS = 20;
26
+ var MAX_KEY_MOMENTS = 20;
27
+ var FRAME_EDGE_MARGIN_SECONDS = 0.35;
28
+ var TranscriptAnalyzer = class {
29
+ /**
30
+ * Analyze transcript segments and return key moments where frames
31
+ * should be extracted from the video recording.
32
+ *
33
+ * @param segments - Array of transcript segments with timing info
34
+ * @param aiHints - Optional AI-informed key-moment hints to merge
35
+ * @returns Array of key moments sorted by timestamp, capped at 20
36
+ */
37
+ analyze(segments, aiHints = []) {
38
+ if (segments.length === 0) {
39
+ return [];
40
+ }
41
+ const moments = [];
42
+ const firstSegment = segments[0];
43
+ const lastSegment = segments[segments.length - 1];
44
+ const sessionDuration = lastSegment.endTime - firstSegment.startTime;
45
+ const startOffset = sessionDuration > FRAME_EDGE_MARGIN_SECONDS ? FRAME_EDGE_MARGIN_SECONDS : 0;
46
+ moments.push({
47
+ timestamp: firstSegment.startTime + startOffset,
48
+ reason: "Session start",
49
+ confidence: 1
50
+ });
51
+ for (let i = 1; i < segments.length; i++) {
52
+ const prev = segments[i - 1];
53
+ const curr = segments[i];
54
+ const gap = curr.startTime - prev.endTime;
55
+ if (gap >= PAUSE_THRESHOLD_SECONDS) {
56
+ moments.push({
57
+ timestamp: prev.endTime,
58
+ reason: "Natural pause in narration",
59
+ confidence: Math.min(1, gap / 3)
60
+ // Longer pauses = higher confidence
61
+ });
62
+ }
63
+ }
64
+ if (lastSegment.endTime > firstSegment.startTime + startOffset) {
65
+ moments.push({
66
+ timestamp: lastSegment.endTime,
67
+ reason: "Session end",
68
+ confidence: 1
69
+ });
70
+ }
71
+ if (moments.length < 3 && aiHints.length === 0) {
72
+ if (sessionDuration > PERIODIC_INTERVAL_SECONDS) {
73
+ const rawCount = Math.floor(sessionDuration / PERIODIC_INTERVAL_SECONDS);
74
+ const interval = Math.min(
75
+ sessionDuration / rawCount,
76
+ MAX_PERIODIC_INTERVAL_SECONDS
77
+ );
78
+ for (let t = firstSegment.startTime + interval; t < lastSegment.endTime; t += interval) {
79
+ moments.push({
80
+ timestamp: t,
81
+ reason: "Periodic capture",
82
+ confidence: 0.5
83
+ });
84
+ }
85
+ }
86
+ }
87
+ for (const hint of aiHints) {
88
+ if (!Number.isFinite(hint.timestamp)) {
89
+ continue;
90
+ }
91
+ moments.push({
92
+ timestamp: Math.max(0, hint.timestamp),
93
+ reason: hint.reason?.trim() || "AI-highlighted context",
94
+ confidence: Math.max(0, Math.min(1, Number.isFinite(hint.confidence) ? hint.confidence : 0.8))
95
+ });
96
+ }
97
+ const deduped = this.deduplicateMoments(moments);
98
+ deduped.sort((a, b) => a.timestamp - b.timestamp);
99
+ if (deduped.length > MAX_KEY_MOMENTS) {
100
+ const first = deduped[0];
101
+ const last = deduped[deduped.length - 1];
102
+ const middle = deduped.slice(1, -1).sort((a, b) => {
103
+ const priorityDelta = this.momentPriority(b) - this.momentPriority(a);
104
+ if (priorityDelta !== 0) {
105
+ return priorityDelta;
106
+ }
107
+ return b.confidence - a.confidence;
108
+ }).slice(0, MAX_KEY_MOMENTS - 2);
109
+ const capped = [first, ...middle, last];
110
+ capped.sort((a, b) => a.timestamp - b.timestamp);
111
+ return capped;
112
+ }
113
+ return deduped;
114
+ }
115
+ /**
116
+ * Remove moments that are within 1 second of each other,
117
+ * keeping the one with higher confidence.
118
+ */
119
+ deduplicateMoments(moments) {
120
+ if (moments.length <= 1) {
121
+ return moments;
122
+ }
123
+ const sorted = [...moments].sort((a, b) => a.timestamp - b.timestamp);
124
+ const result = [sorted[0]];
125
+ for (let i = 1; i < sorted.length; i++) {
126
+ const prev = result[result.length - 1];
127
+ const curr = sorted[i];
128
+ if (curr.timestamp - prev.timestamp < 1) {
129
+ const currPriority = this.momentPriority(curr);
130
+ const prevPriority = this.momentPriority(prev);
131
+ if (currPriority > prevPriority || currPriority === prevPriority && curr.confidence > prev.confidence) {
132
+ result[result.length - 1] = curr;
133
+ }
134
+ } else {
135
+ result.push(curr);
136
+ }
137
+ }
138
+ return result;
139
+ }
140
+ momentPriority(moment) {
141
+ const reason = (moment.reason || "").toLowerCase();
142
+ if (reason.includes("session start") || reason.includes("session end")) {
143
+ return 4;
144
+ }
145
+ if (reason.includes("ai-") || reason.includes("ai ")) {
146
+ return 3;
147
+ }
148
+ if (reason.includes("natural pause")) {
149
+ return 2;
150
+ }
151
+ if (reason.includes("periodic")) {
152
+ return 0;
153
+ }
154
+ return 1;
155
+ }
156
+ };
157
+ var transcriptAnalyzer = new TranscriptAnalyzer();
158
+
159
+ // src/main/pipeline/FrameExtractor.ts
160
+ import { execFile as execFileCb } from "child_process";
161
+ import { promisify } from "util";
162
+ import { existsSync, mkdirSync } from "fs";
163
+ import { stat as statFile } from "fs/promises";
164
+ import { join } from "path";
165
+ var execFile = promisify(execFileCb);
166
+ var DEFAULT_MAX_FRAMES = 20;
167
+ var FFMPEG_ACCURATE_FRAME_TIMEOUT_MS = 2e4;
168
+ var FFMPEG_FAST_FRAME_TIMEOUT_MS = 1e4;
169
+ var FFMPEG_CHECK_TIMEOUT_MS = 5e3;
170
+ var FRAME_EDGE_MARGIN_SECONDS2 = 0.35;
171
+ var TIMESTAMP_DEDUPE_WINDOW_SECONDS = 0.15;
172
+ var SAFE_CHILD_ENV = {
173
+ PATH: process.env.PATH,
174
+ HOME: process.env.HOME || process.env.USERPROFILE,
175
+ USERPROFILE: process.env.USERPROFILE,
176
+ LANG: process.env.LANG,
177
+ TMPDIR: process.env.TMPDIR || process.env.TEMP,
178
+ TEMP: process.env.TEMP
179
+ };
180
+ var FrameExtractor = class {
181
+ ffmpegPath = "ffmpeg";
182
+ ffprobePath = "ffprobe";
183
+ ffmpegChecked = false;
184
+ ffmpegAvailable = false;
185
+ /**
186
+ * Check if ffmpeg is installed and accessible on the system PATH.
187
+ * Result is cached after the first successful check.
188
+ */
189
+ async checkFfmpeg() {
190
+ if (this.ffmpegChecked) {
191
+ return this.ffmpegAvailable;
192
+ }
193
+ try {
194
+ await execFile(this.ffmpegPath, ["-version"], {
195
+ timeout: FFMPEG_CHECK_TIMEOUT_MS,
196
+ env: SAFE_CHILD_ENV
197
+ });
198
+ this.ffmpegAvailable = true;
199
+ this.log("ffmpeg is available");
200
+ } catch {
201
+ this.ffmpegAvailable = false;
202
+ this.log("ffmpeg is not available - frame extraction will be skipped");
203
+ }
204
+ this.ffmpegChecked = true;
205
+ return this.ffmpegAvailable;
206
+ }
207
+ /**
208
+ * Extract frames from a video file at the specified timestamps.
209
+ *
210
+ * @param request - Extraction parameters (video path, timestamps, output dir)
211
+ * @returns Result with extracted frame paths and ffmpeg availability status
212
+ */
213
+ async extract(request) {
214
+ const available = await this.checkFfmpeg();
215
+ if (!available) {
216
+ return { frames: [], ffmpegAvailable: false };
217
+ }
218
+ const maxFrames = request.maxFrames ?? DEFAULT_MAX_FRAMES;
219
+ let timestamps = [...request.timestamps].sort((a, b) => a - b);
220
+ if (timestamps.length > maxFrames) {
221
+ timestamps = this.selectDistributed(timestamps, maxFrames);
222
+ }
223
+ const videoDurationSeconds = await this.getVideoDurationSeconds(request.videoPath);
224
+ timestamps = this.normalizeTimestamps(timestamps, videoDurationSeconds);
225
+ if (timestamps.length === 0) {
226
+ return { frames: [], ffmpegAvailable: true };
227
+ }
228
+ const screenshotsDir = join(request.outputDir, "screenshots");
229
+ if (!existsSync(screenshotsDir)) {
230
+ mkdirSync(screenshotsDir, { recursive: true });
231
+ }
232
+ const frames = [];
233
+ for (let i = 0; i < timestamps.length; i++) {
234
+ const timestamp = timestamps[i];
235
+ const frameNumber = String(i + 1).padStart(3, "0");
236
+ const outputPath = join(screenshotsDir, `frame-${frameNumber}.png`);
237
+ try {
238
+ await this.extractSingleFrame(request.videoPath, timestamp, outputPath);
239
+ const stats = await statFile(outputPath).catch(() => null);
240
+ if (!stats || stats.size <= 0) {
241
+ throw new Error(`ffmpeg did not produce a frame file at timestamp ${timestamp.toFixed(1)}s. The video may be shorter than expected or the codec may not support seeking.`);
242
+ }
243
+ frames.push({
244
+ path: outputPath,
245
+ timestamp,
246
+ success: true
247
+ });
248
+ this.log(`Extracted frame ${frameNumber} at ${timestamp.toFixed(2)}s`);
249
+ } catch (error) {
250
+ const message = error instanceof Error ? error.message : String(error);
251
+ this.log(`Failed to extract frame at ${timestamp.toFixed(2)}s: ${message}`);
252
+ frames.push({
253
+ path: outputPath,
254
+ timestamp,
255
+ success: false
256
+ });
257
+ }
258
+ }
259
+ return { frames, ffmpegAvailable: true };
260
+ }
261
+ // ============================================================================
262
+ // Private Methods
263
+ // ============================================================================
264
+ /**
265
+ * Extract a single frame from the video at the given timestamp.
266
+ */
267
+ async extractSingleFrame(videoPath, timestamp, outputPath) {
268
+ try {
269
+ await this.extractSingleFrameAccurate(videoPath, timestamp, outputPath);
270
+ return;
271
+ } catch (accurateError) {
272
+ this.log(
273
+ `Accurate extraction failed at ${timestamp.toFixed(2)}s, retrying fast seek: ${accurateError instanceof Error ? accurateError.message : String(accurateError)}`
274
+ );
275
+ }
276
+ await this.extractSingleFrameFast(videoPath, timestamp, outputPath);
277
+ }
278
+ async extractSingleFrameAccurate(videoPath, timestamp, outputPath) {
279
+ const args = [
280
+ "-i",
281
+ videoPath,
282
+ "-ss",
283
+ String(timestamp),
284
+ "-frames:v",
285
+ "1",
286
+ "-vf",
287
+ "format=rgb24",
288
+ "-q:v",
289
+ "2",
290
+ "-y",
291
+ outputPath
292
+ ];
293
+ await execFile(this.ffmpegPath, args, {
294
+ timeout: FFMPEG_ACCURATE_FRAME_TIMEOUT_MS,
295
+ env: SAFE_CHILD_ENV
296
+ });
297
+ }
298
+ async extractSingleFrameFast(videoPath, timestamp, outputPath) {
299
+ const args = [
300
+ "-ss",
301
+ String(timestamp),
302
+ "-i",
303
+ videoPath,
304
+ "-frames:v",
305
+ "1",
306
+ "-vf",
307
+ "format=rgb24",
308
+ "-q:v",
309
+ "2",
310
+ "-y",
311
+ // overwrite output file if it exists
312
+ outputPath
313
+ ];
314
+ await execFile(this.ffmpegPath, args, {
315
+ timeout: FFMPEG_FAST_FRAME_TIMEOUT_MS,
316
+ env: SAFE_CHILD_ENV
317
+ });
318
+ }
319
+ /**
320
+ * Select evenly distributed timestamps from a sorted array.
321
+ * Always includes the first and last timestamp.
322
+ */
323
+ selectDistributed(sorted, count) {
324
+ if (sorted.length <= count) {
325
+ return sorted;
326
+ }
327
+ if (count <= 0) {
328
+ return [];
329
+ }
330
+ if (count === 1) {
331
+ return [sorted[0]];
332
+ }
333
+ const result = [sorted[0]];
334
+ const step2 = (sorted.length - 1) / (count - 1);
335
+ for (let i = 1; i < count - 1; i++) {
336
+ const index = Math.round(i * step2);
337
+ result.push(sorted[index]);
338
+ }
339
+ result.push(sorted[sorted.length - 1]);
340
+ return result;
341
+ }
342
+ async getVideoDurationSeconds(videoPath) {
343
+ try {
344
+ const { stdout } = await execFile(
345
+ this.ffprobePath,
346
+ [
347
+ "-v",
348
+ "error",
349
+ "-show_entries",
350
+ "format=duration",
351
+ "-of",
352
+ "default=noprint_wrappers=1:nokey=1",
353
+ videoPath
354
+ ],
355
+ { timeout: FFMPEG_CHECK_TIMEOUT_MS, env: SAFE_CHILD_ENV }
356
+ );
357
+ const parsed = Number.parseFloat(String(stdout).trim());
358
+ if (Number.isFinite(parsed) && parsed > 0) {
359
+ return parsed;
360
+ }
361
+ return null;
362
+ } catch (error) {
363
+ this.log(`ffprobe duration probe failed: ${error instanceof Error ? error.message : String(error)}`);
364
+ return null;
365
+ }
366
+ }
367
+ normalizeTimestamps(timestamps, durationSeconds) {
368
+ const cleaned = timestamps.map((timestamp) => Number.isFinite(timestamp) ? Math.max(0, timestamp) : 0).sort((a, b) => a - b);
369
+ if (cleaned.length === 0) {
370
+ return [];
371
+ }
372
+ let clamped = cleaned;
373
+ if (durationSeconds && durationSeconds > 0) {
374
+ const minTs = Math.min(FRAME_EDGE_MARGIN_SECONDS2, Math.max(0, durationSeconds - 0.05));
375
+ const maxTs = Math.max(minTs, durationSeconds - FRAME_EDGE_MARGIN_SECONDS2);
376
+ clamped = cleaned.map((timestamp) => Math.max(minTs, Math.min(timestamp, maxTs)));
377
+ }
378
+ const deduped = [];
379
+ for (const timestamp of clamped) {
380
+ const previous = deduped[deduped.length - 1];
381
+ if (previous === void 0 || Math.abs(timestamp - previous) >= TIMESTAMP_DEDUPE_WINDOW_SECONDS) {
382
+ deduped.push(timestamp);
383
+ }
384
+ }
385
+ return deduped;
386
+ }
387
+ /**
388
+ * Log helper with consistent prefix.
389
+ */
390
+ log(message) {
391
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
392
+ console.log(`[FrameExtractor ${timestamp}] ${message}`);
393
+ }
394
+ };
395
+ var frameExtractor = new FrameExtractor();
396
+
397
+ // src/main/output/MarkdownGenerator.ts
398
+ import * as path from "path";
399
+ var REPORT_SUPPORT_LINE = "*If this report saved you time, support development: [Ko-fi](https://ko-fi.com/eddiesanjuan)*";
400
+ var MarkdownGeneratorImpl = class {
401
+ /**
402
+ * Generate a full markdown document with all feedback items and metadata.
403
+ * Follows llms.txt-inspired format for AI readability.
404
+ */
405
+ generateFullDocument(session, options) {
406
+ const { projectName, screenshotDir } = options;
407
+ const items = session.feedbackItems;
408
+ const duration = session.endTime ? this.formatDuration(session.endTime - session.startTime) : "In Progress";
409
+ const timestamp = this.formatTimestamp(session.endTime || Date.now());
410
+ const filename = this.generateFilename(projectName, session.startTime);
411
+ if (items.length === 0) {
412
+ const content2 = `# ${projectName} Feedback Report
413
+ > Generated by markupr on ${timestamp}
414
+ > Duration: ${duration}
415
+
416
+ _No feedback items were captured during this session._
417
+
418
+ ---
419
+ *Generated by [markupr](https://markupr.com)*
420
+ ${REPORT_SUPPORT_LINE}
421
+ `;
422
+ return {
423
+ content: content2,
424
+ filename,
425
+ metadata: {
426
+ itemCount: 0,
427
+ screenshotCount: 0,
428
+ duration: session.endTime ? session.endTime - session.startTime : 0,
429
+ types: {}
430
+ }
431
+ };
432
+ }
433
+ const typeCounts = this.countTypes(items);
434
+ const severityCounts = this.countSeverities(items);
435
+ const screenshotCount = this.countScreenshots(items);
436
+ const topThemes = this.extractTopThemes(items);
437
+ const highImpactCount = (severityCounts.Critical || 0) + (severityCounts.High || 0);
438
+ const platform = session.metadata?.os || process?.platform || "Unknown";
439
+ let content = `# ${projectName} Feedback Report
440
+ > Generated by markupr on ${timestamp}
441
+ > Duration: ${duration} | Items: ${items.length} | Screenshots: ${screenshotCount}
442
+
443
+ ## Session Overview
444
+ - **Session ID:** \`${session.id}\`
445
+ - **Source:** ${session.metadata?.sourceName || "Unknown"} (${session.metadata?.sourceType || "screen"})
446
+ - **Platform:** ${platform}
447
+ - **Segments:** ${items.length}
448
+ - **High-impact items:** ${highImpactCount}
449
+
450
+ ---
451
+
452
+ ## Executive Summary
453
+
454
+ - ${items.length} total feedback items were captured.
455
+ - ${highImpactCount} items are categorized as **Critical** or **High** priority.
456
+ - ${screenshotCount} screenshots were aligned to spoken context.
457
+ `;
458
+ if (topThemes.length > 0) {
459
+ content += `- Top themes: ${topThemes.join(", ")}.
460
+ `;
461
+ }
462
+ content += `
463
+ ---
464
+
465
+ ## Actionable Feedback
466
+
467
+ `;
468
+ items.forEach((item, index) => {
469
+ const id = this.generateFeedbackItemId(index);
470
+ const title = item.title || this.generateTitle(item.transcription);
471
+ const itemTimestamp = this.formatItemTimestamp(item.timestamp - session.startTime);
472
+ const category = item.category || "General";
473
+ const severity = item.severity || this.defaultSeverityForCategory(category);
474
+ const signals = item.keywords?.slice(0, 5) || [];
475
+ const suggestedAction = this.suggestAction(category, severity, item.transcription);
476
+ content += `### ${id}: ${title}
477
+ - **Severity:** ${severity}
478
+ - **Type:** ${category}
479
+ - **Timestamp:** ${itemTimestamp}
480
+ `;
481
+ if (signals.length > 0) {
482
+ content += `- **Signals:** ${signals.join(", ")}
483
+ `;
484
+ }
485
+ content += `
486
+ #### What Happened
487
+
488
+ > ${this.wrapTranscription(item.transcription)}
489
+
490
+ `;
491
+ if (item.screenshots.length > 0) {
492
+ content += `#### Evidence
493
+ `;
494
+ item.screenshots.forEach((ss, ssIndex) => {
495
+ const screenshotFilename = this.generateScreenshotFilename(index, ssIndex, item.screenshots.length);
496
+ content += `![${id}${item.screenshots.length > 1 ? `-${ssIndex + 1}` : ""}](${screenshotDir}/${screenshotFilename})
497
+
498
+ `;
499
+ });
500
+ } else {
501
+ content += `#### Evidence
502
+ _No screenshot captured for this item._
503
+
504
+ `;
505
+ }
506
+ content += `#### Suggested Next Step
507
+ - ${suggestedAction}
508
+
509
+ `;
510
+ content += `---
511
+
512
+ `;
513
+ });
514
+ content += `## Summary
515
+
516
+ | Type | Count |
517
+ |------|-------|
518
+ `;
519
+ Object.entries(typeCounts).forEach(([type, count]) => {
520
+ content += `| ${type} | ${count} |
521
+ `;
522
+ });
523
+ content += `| **Total** | **${items.length}** |
524
+ `;
525
+ content += `
526
+ | Severity | Count |
527
+ |----------|-------|
528
+ `;
529
+ Object.entries(severityCounts).forEach(([severity, count]) => {
530
+ content += `| ${severity} | ${count} |
531
+ `;
532
+ });
533
+ content += `| **Total** | **${items.length}** |
534
+ `;
535
+ content += `
536
+ ---
537
+ *Generated by [markupr](https://markupr.com)*
538
+ ${REPORT_SUPPORT_LINE}
539
+ `;
540
+ return {
541
+ content,
542
+ filename,
543
+ metadata: {
544
+ itemCount: items.length,
545
+ screenshotCount,
546
+ duration: session.endTime ? session.endTime - session.startTime : 0,
547
+ types: typeCounts
548
+ }
549
+ };
550
+ }
551
+ /**
552
+ * Generate markdown from a PostProcessResult (post-recording pipeline output).
553
+ *
554
+ * Produces a clean, AI-readable document with:
555
+ * - Session header with human-readable timestamp
556
+ * - Each transcript segment as a heading with [M:SS] timestamp
557
+ * - Blockquoted transcript text
558
+ * - Associated frame images referenced as relative paths
559
+ *
560
+ * @param result - The combined transcript + frame output from PostProcessor
561
+ * @param sessionDir - Absolute path to the session directory (used to compute relative frame paths)
562
+ * @returns The generated markdown string
563
+ */
564
+ generateFromPostProcess(result, sessionDir) {
565
+ const { transcriptSegments, extractedFrames } = result;
566
+ const sessionTimestamp = this.formatDateDeterministic(/* @__PURE__ */ new Date());
567
+ const sessionDuration = transcriptSegments.length > 0 ? this.formatDuration(
568
+ (transcriptSegments[transcriptSegments.length - 1].endTime - transcriptSegments[0].startTime) * 1e3
569
+ ) : "0:00";
570
+ let md = `# markupr Session \u2014 ${sessionTimestamp}
571
+ `;
572
+ md += `> Segments: ${transcriptSegments.length} | Frames: ${extractedFrames.length} | Duration: ${sessionDuration}
573
+
574
+ `;
575
+ if (transcriptSegments.length === 0) {
576
+ md += `_No speech was detected during this recording._
577
+ `;
578
+ return md;
579
+ }
580
+ md += `## Transcript
581
+
582
+ `;
583
+ const segmentFrameMap = this.mapFramesToSegments(transcriptSegments, extractedFrames);
584
+ for (let i = 0; i < transcriptSegments.length; i++) {
585
+ const segment = transcriptSegments[i];
586
+ const formattedTime = this.formatPostProcessTimestamp(segment.startTime);
587
+ const title = this.generateSegmentTitle(segment.text);
588
+ md += `### [${formattedTime}] ${title}
589
+ `;
590
+ md += `> ${this.wrapTranscription(segment.text)}
591
+
592
+ `;
593
+ const frames = segmentFrameMap.get(i);
594
+ if (frames && frames.length > 0) {
595
+ for (const frame of frames) {
596
+ const frameTimestamp = this.formatPostProcessTimestamp(frame.timestamp);
597
+ const relativePath = this.computeRelativeFramePath(frame.path, sessionDir);
598
+ md += `![Frame at ${frameTimestamp}](${relativePath})
599
+
600
+ `;
601
+ }
602
+ }
603
+ }
604
+ md += `---
605
+ *Generated by [markupr](https://markupr.com)*
606
+ ${REPORT_SUPPORT_LINE}
607
+ `;
608
+ return md;
609
+ }
610
+ /**
611
+ * Map extracted frames to their closest transcript segments.
612
+ * Returns a Map from segment index to an array of frames.
613
+ */
614
+ mapFramesToSegments(segments, frames) {
615
+ const map = /* @__PURE__ */ new Map();
616
+ for (const frame of frames) {
617
+ let bestIndex = 0;
618
+ let bestDistance = Infinity;
619
+ for (let i = 0; i < segments.length; i++) {
620
+ const seg = segments[i];
621
+ if (frame.timestamp >= seg.startTime && frame.timestamp <= seg.endTime) {
622
+ bestIndex = i;
623
+ bestDistance = 0;
624
+ break;
625
+ }
626
+ const distance = Math.abs(frame.timestamp - seg.startTime);
627
+ if (distance < bestDistance) {
628
+ bestDistance = distance;
629
+ bestIndex = i;
630
+ }
631
+ }
632
+ const existing = map.get(bestIndex) || [];
633
+ existing.push(frame);
634
+ map.set(bestIndex, existing);
635
+ }
636
+ for (const [, frameList] of map) {
637
+ frameList.sort((a, b) => a.timestamp - b.timestamp);
638
+ }
639
+ return map;
640
+ }
641
+ /**
642
+ * Compute a relative path for a frame image from the session directory.
643
+ * If the frame path is already relative, return it as-is.
644
+ * If absolute, compute the relative path from sessionDir.
645
+ */
646
+ computeRelativeFramePath(framePath, sessionDir) {
647
+ if (!path.isAbsolute(framePath)) {
648
+ return framePath;
649
+ }
650
+ return path.relative(sessionDir, framePath);
651
+ }
652
+ /**
653
+ * Format a timestamp in seconds to M:SS format for post-process output.
654
+ * Examples: 0 -> "0:00", 15.3 -> "0:15", 125 -> "2:05"
655
+ */
656
+ formatPostProcessTimestamp(seconds) {
657
+ const totalSeconds = Math.max(0, Math.floor(seconds));
658
+ const mins = Math.floor(totalSeconds / 60);
659
+ const secs = totalSeconds % 60;
660
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
661
+ }
662
+ /**
663
+ * Generate a short title from transcript text (first sentence, max 60 chars).
664
+ */
665
+ generateSegmentTitle(text) {
666
+ const firstSentence = text.split(/[.!?]/)[0].trim();
667
+ if (firstSentence.length <= 60) return firstSentence;
668
+ return firstSentence.slice(0, 57) + "...";
669
+ }
670
+ /**
671
+ * Generate a clipboard-friendly summary (<1500 chars).
672
+ * Includes priority items and a reference to the full report.
673
+ *
674
+ * @param session - Session data
675
+ * @param projectName - Optional project name for the header
676
+ * @param reportPath - Optional absolute or relative path to the full report file.
677
+ * When provided, the summary links to this path instead of the
678
+ * generic ./feedback-report.md placeholder.
679
+ */
680
+ generateClipboardSummary(session, projectName, reportPath) {
681
+ const name = projectName || session.metadata?.sourceName || "Project";
682
+ const items = session.feedbackItems;
683
+ let summary = `# Feedback: ${name} - ${items.length} items
684
+
685
+ `;
686
+ const maxPriorityItems = 3;
687
+ summary += `## Priority Items
688
+ `;
689
+ items.slice(0, maxPriorityItems).forEach((item, index) => {
690
+ const id = this.generateFeedbackItemId(index);
691
+ const title = this.generateTitle(item.transcription);
692
+ const oneLineSummary = this.truncateText(item.transcription, 60);
693
+ summary += `- **${id}:** ${title} - ${oneLineSummary}
694
+ `;
695
+ });
696
+ if (items.length > maxPriorityItems) {
697
+ const remainingIds = items.slice(maxPriorityItems).map((_, i) => this.generateFeedbackItemId(i + maxPriorityItems)).join(", ");
698
+ summary += `
699
+ ## Other
700
+ - ${remainingIds} (see full report)
701
+ `;
702
+ }
703
+ summary += `
704
+ **Full report:** ${reportPath || "./feedback-report.md"}`;
705
+ if (summary.length > 1500) {
706
+ summary = summary.slice(0, 1497) + "...";
707
+ }
708
+ return summary;
709
+ }
710
+ /**
711
+ * Generate a feedback item ID (FB-001, FB-002, etc.)
712
+ */
713
+ generateFeedbackItemId(index) {
714
+ return `FB-${(index + 1).toString().padStart(3, "0")}`;
715
+ }
716
+ // ==========================================================================
717
+ // Private Helper Methods
718
+ // ==========================================================================
719
+ /**
720
+ * Generate a title from the transcription (first sentence or 50 chars)
721
+ */
722
+ generateTitle(transcription) {
723
+ const firstSentence = transcription.split(/[.!?]/)[0].trim();
724
+ if (firstSentence.length <= 50) return firstSentence;
725
+ return firstSentence.slice(0, 47) + "...";
726
+ }
727
+ /**
728
+ * Truncate text to specified length
729
+ */
730
+ truncateText(text, maxLength) {
731
+ if (text.length <= maxLength) return text;
732
+ return text.slice(0, maxLength - 3) + "...";
733
+ }
734
+ /**
735
+ * Wrap transcription for markdown blockquote (handle multi-line).
736
+ * Splits on sentence-ending punctuation followed by whitespace so that
737
+ * all multi-sentence inputs (including 2-sentence ones) get proper
738
+ * blockquote continuation lines.
739
+ */
740
+ wrapTranscription(transcription) {
741
+ if (!transcription.includes(".") && !transcription.includes("!") && !transcription.includes("?")) {
742
+ return transcription;
743
+ }
744
+ const sentences = transcription.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
745
+ if (sentences.length <= 1) return transcription;
746
+ return sentences.join("\n> ");
747
+ }
748
+ /**
749
+ * Format duration from milliseconds to M:SS
750
+ */
751
+ formatDuration(ms) {
752
+ const totalSeconds = Math.floor(ms / 1e3);
753
+ const mins = Math.floor(totalSeconds / 60);
754
+ const secs = totalSeconds % 60;
755
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
756
+ }
757
+ /**
758
+ * Format timestamp to a deterministic human-readable string.
759
+ * Uses explicit formatting instead of toLocaleString to produce
760
+ * consistent output across platforms and Node.js versions.
761
+ */
762
+ formatTimestamp(ms) {
763
+ return this.formatDateDeterministic(new Date(ms));
764
+ }
765
+ /**
766
+ * Produce a deterministic date string: "Feb 14, 2026 at 10:30 AM".
767
+ * Avoids toLocaleString which can vary across OS versions.
768
+ */
769
+ formatDateDeterministic(date) {
770
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
771
+ const month = months[date.getMonth()];
772
+ const day = date.getDate();
773
+ const year = date.getFullYear();
774
+ const rawHours = date.getHours();
775
+ const ampm = rawHours >= 12 ? "PM" : "AM";
776
+ const hours = rawHours % 12 || 12;
777
+ const minutes = date.getMinutes().toString().padStart(2, "0");
778
+ return `${month} ${day}, ${year} at ${hours}:${minutes} ${ampm}`;
779
+ }
780
+ /**
781
+ * Format item timestamp as MM:SS from session start
782
+ */
783
+ formatItemTimestamp(ms) {
784
+ const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
785
+ const mins = Math.floor(totalSeconds / 60);
786
+ const secs = totalSeconds % 60;
787
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
788
+ }
789
+ /**
790
+ * Generate filename following pattern: {project}-feedback-{YYYYMMDD-HHmmss}.md
791
+ */
792
+ generateFilename(projectName, startTime) {
793
+ const date = new Date(startTime);
794
+ const year = date.getFullYear();
795
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
796
+ const day = date.getDate().toString().padStart(2, "0");
797
+ const hours = date.getHours().toString().padStart(2, "0");
798
+ const minutes = date.getMinutes().toString().padStart(2, "0");
799
+ const seconds = date.getSeconds().toString().padStart(2, "0");
800
+ const dateStr = `${year}${month}${day}`;
801
+ const timeStr = `${hours}${minutes}${seconds}`;
802
+ const safeName = projectName.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-");
803
+ return `${safeName}-feedback-${dateStr}-${timeStr}.md`;
804
+ }
805
+ /**
806
+ * Generate screenshot filename for a feedback item.
807
+ * Uses the item's position index to produce `fb-{NNN}.png`, matching the
808
+ * naming convention in FileManager.saveSession.
809
+ */
810
+ generateScreenshotFilename(itemIndex, screenshotIndex, total) {
811
+ const num = (itemIndex + 1).toString().padStart(3, "0");
812
+ const suffix = total > 1 ? `-${screenshotIndex + 1}` : "";
813
+ return `fb-${num}${suffix}.png`;
814
+ }
815
+ /**
816
+ * Provide a severity fallback when upstream analysis is unavailable.
817
+ */
818
+ defaultSeverityForCategory(category) {
819
+ switch (category) {
820
+ case "Bug":
821
+ return "High";
822
+ case "Performance":
823
+ return "High";
824
+ case "UX Issue":
825
+ return "Medium";
826
+ case "Suggestion":
827
+ return "Low";
828
+ case "Question":
829
+ return "Low";
830
+ default:
831
+ return "Medium";
832
+ }
833
+ }
834
+ countSeverities(items) {
835
+ return items.reduce((acc, item) => {
836
+ const severity = item.severity || this.defaultSeverityForCategory(item.category || "General");
837
+ acc[severity] = (acc[severity] || 0) + 1;
838
+ return acc;
839
+ }, {});
840
+ }
841
+ extractTopThemes(items) {
842
+ const counts = /* @__PURE__ */ new Map();
843
+ items.forEach((item) => {
844
+ (item.keywords || []).forEach((keyword) => {
845
+ const normalized = keyword.toLowerCase();
846
+ counts.set(normalized, (counts.get(normalized) || 0) + 1);
847
+ });
848
+ });
849
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([keyword]) => keyword);
850
+ }
851
+ suggestAction(category, severity, transcription) {
852
+ const excerpt = this.truncateText(transcription, 120);
853
+ switch (category) {
854
+ case "Bug":
855
+ return `Reproduce and patch this defect, then add a regression test that validates: "${excerpt}".`;
856
+ case "Performance":
857
+ return `Profile this flow, target the slow step first, and validate before/after metrics for: "${excerpt}".`;
858
+ case "UX Issue":
859
+ return `Revise the UI interaction and run a quick usability check focused on: "${excerpt}".`;
860
+ case "Suggestion":
861
+ return severity === "High" || severity === "Critical" ? `Treat this suggestion as near-term roadmap work and define implementation scope for: "${excerpt}".` : `Track this as an enhancement request and prioritize against current sprint goals: "${excerpt}".`;
862
+ case "Question":
863
+ return `Answer this explicitly in product/docs so future reviews don't block on: "${excerpt}".`;
864
+ default:
865
+ return `Investigate this item and convert it into a concrete engineering task: "${excerpt}".`;
866
+ }
867
+ }
868
+ /**
869
+ * Count feedback items by type/category
870
+ */
871
+ countTypes(items) {
872
+ return items.reduce((acc, item) => {
873
+ const type = item.category || "General";
874
+ acc[type] = (acc[type] || 0) + 1;
875
+ return acc;
876
+ }, {});
877
+ }
878
+ /**
879
+ * Count total screenshots across all items
880
+ */
881
+ countScreenshots(items) {
882
+ return items.reduce((sum, item) => sum + item.screenshots.length, 0);
883
+ }
884
+ };
885
+ var markdownGenerator = new MarkdownGeneratorImpl();
886
+
887
+ // src/main/transcription/WhisperService.ts
888
+ import { EventEmitter } from "events";
889
+ import { basename, join as join2 } from "path";
890
+ import { existsSync as existsSync2 } from "fs";
891
+ import { readFile, unlink, chmod } from "fs/promises";
892
+ import { execFile as execFile2 } from "child_process";
893
+ import { promisify as promisify2 } from "util";
894
+ import { tmpdir } from "os";
895
+ import { randomUUID } from "crypto";
896
+ import * as os from "os";
897
+ var execFileAsync = promisify2(execFile2);
898
+ var DEFAULT_CONFIG = {
899
+ modelPath: "",
900
+ // Set dynamically
901
+ language: "en",
902
+ threads: Math.max(1, Math.floor(os.cpus().length / 2)),
903
+ // Half CPU cores
904
+ translateToEnglish: false
905
+ };
906
+ var CHUNK_DURATION_MS = 3e3;
907
+ var MAX_BUFFER_DURATION_MS = 3e4;
908
+ var MAX_BUFFER_SIZE_BYTES = 500 * 1024;
909
+ var SAMPLE_RATE = 16e3;
910
+ var FILE_CHUNK_DURATION_SEC = 30;
911
+ var FILE_CHUNK_SAMPLES = FILE_CHUNK_DURATION_SEC * SAMPLE_RATE;
912
+ var MODEL_MEMORY_REQUIREMENTS_BYTES = {
913
+ "ggml-tiny.bin": 450 * 1024 * 1024,
914
+ "ggml-base.bin": 800 * 1024 * 1024,
915
+ "ggml-small.bin": 1400 * 1024 * 1024,
916
+ "ggml-medium.bin": 2800 * 1024 * 1024,
917
+ "ggml-large-v3.bin": 5200 * 1024 * 1024
918
+ };
919
+ var WhisperService = class extends EventEmitter {
920
+ config;
921
+ isInitialized = false;
922
+ isProcessing = false;
923
+ whisperModule = null;
924
+ // Audio buffering for batch processing
925
+ audioBuffer = [];
926
+ bufferStartTime = 0;
927
+ totalBufferDuration = 0;
928
+ totalBufferBytes = 0;
929
+ // Processing state
930
+ processingInterval = null;
931
+ // Callbacks
932
+ transcriptCallbacks = [];
933
+ errorCallbacks = [];
934
+ constructor(config) {
935
+ super();
936
+ this.config = { ...DEFAULT_CONFIG, ...config };
937
+ if (!this.config.modelPath) {
938
+ this.config.modelPath = this.getDefaultModelPath();
939
+ }
940
+ }
941
+ // ============================================================================
942
+ // Public API
943
+ // ============================================================================
944
+ /**
945
+ * Check if Whisper model is available
946
+ */
947
+ isModelAvailable() {
948
+ return existsSync2(this.config.modelPath);
949
+ }
950
+ /**
951
+ * Get the path where models should be stored
952
+ */
953
+ getModelsDirectory() {
954
+ try {
955
+ const { app } = __require("electron");
956
+ return join2(app.getPath("userData"), "whisper-models");
957
+ } catch {
958
+ const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir();
959
+ return join2(homeDir, ".markupr", "whisper-models");
960
+ }
961
+ }
962
+ /**
963
+ * Get the default model path (whisper-medium)
964
+ */
965
+ getDefaultModelPath() {
966
+ return join2(this.getModelsDirectory(), "ggml-medium.bin");
967
+ }
968
+ /**
969
+ * Set the model path
970
+ */
971
+ setModelPath(modelPath) {
972
+ this.config.modelPath = modelPath;
973
+ this.isInitialized = false;
974
+ }
975
+ /**
976
+ * Check if system has enough memory for Whisper
977
+ * Requirement is model-aware (tiny/base/small/medium/large).
978
+ */
979
+ hasEnoughMemory() {
980
+ const freeMemory = os.freemem();
981
+ const requiredMemory = this.getRequiredMemoryBytes();
982
+ return freeMemory >= requiredMemory;
983
+ }
984
+ /**
985
+ * Get current memory info
986
+ */
987
+ getMemoryInfo() {
988
+ const freeMemory = os.freemem();
989
+ const requiredMemory = this.getRequiredMemoryBytes();
990
+ return {
991
+ freeMemoryMB: Math.round(freeMemory / 1024 / 1024),
992
+ requiredMemoryMB: Math.round(requiredMemory / 1024 / 1024),
993
+ sufficient: freeMemory >= requiredMemory
994
+ };
995
+ }
996
+ /**
997
+ * Initialize the Whisper model
998
+ * Call this once before starting transcription
999
+ */
1000
+ async initialize() {
1001
+ if (this.isInitialized) {
1002
+ return;
1003
+ }
1004
+ if (!this.isModelAvailable()) {
1005
+ throw new Error(`Whisper model not found at ${this.config.modelPath}. Please download the model first.`);
1006
+ }
1007
+ if (!this.hasEnoughMemory()) {
1008
+ const memInfo = this.getMemoryInfo();
1009
+ throw new Error(
1010
+ `Insufficient memory for Whisper. Need ~${memInfo.requiredMemoryMB}MB free, only ${memInfo.freeMemoryMB}MB available.`
1011
+ );
1012
+ }
1013
+ this.log("Initializing Whisper model...");
1014
+ try {
1015
+ this.whisperModule = await import("whisper-node");
1016
+ if (!this.whisperModule || typeof this.whisperModule.whisper !== "function") {
1017
+ throw new Error("whisper-node module loaded but whisper function not found");
1018
+ }
1019
+ this.log("Pre-loading model with test transcription...");
1020
+ const testBuffer = new Float32Array(1600);
1021
+ await this.whisperModule.whisper(testBuffer, {
1022
+ modelPath: this.config.modelPath,
1023
+ language: this.config.language,
1024
+ threads: this.config.threads
1025
+ });
1026
+ this.isInitialized = true;
1027
+ this.log("Whisper model initialized successfully");
1028
+ } catch (error) {
1029
+ const initError = new Error(`Failed to initialize Whisper: ${error.message}`);
1030
+ this.errorCallbacks.forEach((cb) => cb(initError));
1031
+ throw initError;
1032
+ }
1033
+ }
1034
+ /**
1035
+ * Check if service is initialized and ready
1036
+ */
1037
+ isReady() {
1038
+ return this.isInitialized && this.whisperModule !== null;
1039
+ }
1040
+ /**
1041
+ * Start accepting audio for transcription
1042
+ */
1043
+ async start() {
1044
+ if (!this.isInitialized) {
1045
+ await this.initialize();
1046
+ }
1047
+ this.audioBuffer = [];
1048
+ this.bufferStartTime = Date.now();
1049
+ this.totalBufferDuration = 0;
1050
+ this.totalBufferBytes = 0;
1051
+ this.processingInterval = setInterval(() => {
1052
+ this.processBufferedAudio();
1053
+ }, CHUNK_DURATION_MS);
1054
+ this.log("Whisper transcription started");
1055
+ }
1056
+ /**
1057
+ * Stop transcription and process remaining audio
1058
+ */
1059
+ async stop() {
1060
+ if (this.processingInterval) {
1061
+ clearInterval(this.processingInterval);
1062
+ this.processingInterval = null;
1063
+ }
1064
+ await this.processBufferedAudio(true);
1065
+ this.audioBuffer = [];
1066
+ this.totalBufferDuration = 0;
1067
+ this.totalBufferBytes = 0;
1068
+ this.log("Whisper transcription stopped");
1069
+ }
1070
+ /**
1071
+ * Add audio data to the buffer
1072
+ * @param samples Float32Array of audio samples at 16kHz mono
1073
+ * @param durationMs Duration of this chunk in milliseconds
1074
+ */
1075
+ addAudio(samples, durationMs) {
1076
+ const chunkBytes = samples.byteLength;
1077
+ if (this.totalBufferBytes + chunkBytes > MAX_BUFFER_SIZE_BYTES) {
1078
+ this.log("Audio buffer full, force-processing before adding new audio");
1079
+ this.processBufferedAudio(true);
1080
+ }
1081
+ this.audioBuffer.push(samples);
1082
+ this.totalBufferDuration += durationMs;
1083
+ this.totalBufferBytes += chunkBytes;
1084
+ if (this.totalBufferDuration >= MAX_BUFFER_DURATION_MS) {
1085
+ this.processBufferedAudio(true);
1086
+ }
1087
+ }
1088
+ /**
1089
+ * Register callback for transcript results
1090
+ */
1091
+ onTranscript(callback) {
1092
+ this.transcriptCallbacks.push(callback);
1093
+ return () => {
1094
+ this.transcriptCallbacks = this.transcriptCallbacks.filter((cb) => cb !== callback);
1095
+ };
1096
+ }
1097
+ /**
1098
+ * Register callback for errors
1099
+ */
1100
+ onError(callback) {
1101
+ this.errorCallbacks.push(callback);
1102
+ return () => {
1103
+ this.errorCallbacks = this.errorCallbacks.filter((cb) => cb !== callback);
1104
+ };
1105
+ }
1106
+ /**
1107
+ * Get current configuration
1108
+ */
1109
+ getConfig() {
1110
+ return { ...this.config };
1111
+ }
1112
+ /**
1113
+ * Transcribe a complete Float32 buffer in one pass.
1114
+ * Useful for post-session retry workflows when live streaming failed.
1115
+ */
1116
+ async transcribeSamples(samples, startTimeSec) {
1117
+ if (!this.isInitialized) {
1118
+ await this.initialize();
1119
+ }
1120
+ if (!this.whisperModule) {
1121
+ throw new Error("Whisper module not loaded");
1122
+ }
1123
+ const CHUNK_TIMEOUT_MS = 6e4;
1124
+ let timeoutId;
1125
+ const timeoutPromise = new Promise((_, reject) => {
1126
+ timeoutId = setTimeout(
1127
+ () => reject(new Error("Whisper transcription timed out after 60s")),
1128
+ CHUNK_TIMEOUT_MS
1129
+ );
1130
+ });
1131
+ let result;
1132
+ try {
1133
+ result = await Promise.race([
1134
+ this.whisperModule.whisper(samples, {
1135
+ modelPath: this.config.modelPath,
1136
+ language: this.config.language,
1137
+ threads: this.config.threads,
1138
+ translate: this.config.translateToEnglish
1139
+ }),
1140
+ timeoutPromise
1141
+ ]);
1142
+ } finally {
1143
+ if (timeoutId) clearTimeout(timeoutId);
1144
+ }
1145
+ if (!result || result.length === 0) {
1146
+ return [];
1147
+ }
1148
+ return result.map((segment) => ({
1149
+ text: segment.text.trim(),
1150
+ startTime: startTimeSec + segment.start,
1151
+ endTime: startTimeSec + segment.end,
1152
+ confidence: 0.9
1153
+ })).filter((segment) => segment.text.length > 0);
1154
+ }
1155
+ /**
1156
+ * Transcribe an audio file from disk.
1157
+ * Loads the file, converts to Float32Array at 16kHz mono, and transcribes.
1158
+ * For large files, processes in chunks to manage memory.
1159
+ *
1160
+ * @param audioPath - Path to the audio file (webm, wav, ogg, m4a)
1161
+ * @param onProgress - Optional progress callback (0-100)
1162
+ * @returns Array of transcript results with timestamps
1163
+ */
1164
+ async transcribeFile(audioPath, onProgress) {
1165
+ if (!this.isInitialized) {
1166
+ await this.initialize();
1167
+ }
1168
+ if (!existsSync2(audioPath)) {
1169
+ throw new Error(`Audio file not found: ${audioPath}`);
1170
+ }
1171
+ this.log(`Transcribing file: ${audioPath}`);
1172
+ onProgress?.(0);
1173
+ const samples = await this.loadAudioAsSamples(audioPath);
1174
+ if (samples.length === 0) {
1175
+ this.log("Audio file produced no samples");
1176
+ onProgress?.(100);
1177
+ return [];
1178
+ }
1179
+ const totalChunks = Math.ceil(samples.length / FILE_CHUNK_SAMPLES);
1180
+ const results = [];
1181
+ this.log(`Processing ${totalChunks} chunk(s) (${(samples.length / SAMPLE_RATE).toFixed(1)}s total)`);
1182
+ for (let i = 0; i < totalChunks; i++) {
1183
+ const chunkStart = i * FILE_CHUNK_SAMPLES;
1184
+ const chunkEnd = Math.min(chunkStart + FILE_CHUNK_SAMPLES, samples.length);
1185
+ const chunk = samples.subarray(chunkStart, chunkEnd);
1186
+ const startTimeSec = chunkStart / SAMPLE_RATE;
1187
+ const chunkResults = await this.transcribeSamples(chunk, startTimeSec);
1188
+ results.push(...chunkResults);
1189
+ const percent = Math.round((i + 1) / totalChunks * 100);
1190
+ onProgress?.(percent);
1191
+ if (i < totalChunks - 1) {
1192
+ await new Promise((resolve2) => setTimeout(resolve2, 0));
1193
+ }
1194
+ }
1195
+ this.log(`Transcription complete: ${results.length} segment(s)`);
1196
+ return results;
1197
+ }
1198
+ /**
1199
+ * Check if ffmpeg is available on the system
1200
+ */
1201
+ async isFfmpegAvailable() {
1202
+ try {
1203
+ await execFileAsync("ffmpeg", ["-version"]);
1204
+ return true;
1205
+ } catch {
1206
+ return false;
1207
+ }
1208
+ }
1209
+ // ============================================================================
1210
+ // Private Methods
1211
+ // ============================================================================
1212
+ /**
1213
+ * Load an audio file and return Float32Array samples at 16kHz mono.
1214
+ * WAV files are parsed directly; other formats are converted via ffmpeg.
1215
+ */
1216
+ async loadAudioAsSamples(audioPath) {
1217
+ const ext = audioPath.toLowerCase().split(".").pop() ?? "";
1218
+ if (ext === "wav") {
1219
+ return this.parseWavFile(audioPath);
1220
+ }
1221
+ return this.convertWithFfmpeg(audioPath);
1222
+ }
1223
+ /**
1224
+ * Parse a WAV file and extract PCM data as Float32Array at 16kHz mono.
1225
+ * Handles PCM float32 and PCM int16 formats.
1226
+ */
1227
+ async parseWavFile(wavPath) {
1228
+ const buffer = await readFile(wavPath);
1229
+ const riff = buffer.toString("ascii", 0, 4);
1230
+ const wave = buffer.toString("ascii", 8, 12);
1231
+ if (riff !== "RIFF" || wave !== "WAVE") {
1232
+ throw new Error(`Invalid WAV file: missing RIFF/WAVE header in ${wavPath}`);
1233
+ }
1234
+ let offset = 12;
1235
+ let audioFormat = 0;
1236
+ let numChannels = 0;
1237
+ let sampleRate = 0;
1238
+ let bitsPerSample = 0;
1239
+ let fmtFound = false;
1240
+ while (offset < buffer.length - 8) {
1241
+ const chunkId = buffer.toString("ascii", offset, offset + 4);
1242
+ const chunkSize = buffer.readUInt32LE(offset + 4);
1243
+ if (chunkId === "fmt ") {
1244
+ audioFormat = buffer.readUInt16LE(offset + 8);
1245
+ numChannels = buffer.readUInt16LE(offset + 10);
1246
+ sampleRate = buffer.readUInt32LE(offset + 12);
1247
+ bitsPerSample = buffer.readUInt16LE(offset + 22);
1248
+ fmtFound = true;
1249
+ }
1250
+ if (chunkId === "data") {
1251
+ if (!fmtFound) {
1252
+ throw new Error("WAV file has data chunk before fmt chunk");
1253
+ }
1254
+ const dataStart = offset + 8;
1255
+ const dataEnd = dataStart + chunkSize;
1256
+ const dataSlice = buffer.subarray(dataStart, Math.min(dataEnd, buffer.length));
1257
+ return this.extractWavSamples(dataSlice, audioFormat, numChannels, sampleRate, bitsPerSample);
1258
+ }
1259
+ offset += 8 + chunkSize;
1260
+ if (chunkSize % 2 !== 0) {
1261
+ offset += 1;
1262
+ }
1263
+ }
1264
+ throw new Error(`Invalid WAV file: no data chunk found in ${wavPath}`);
1265
+ }
1266
+ /**
1267
+ * Extract samples from WAV data chunk, converting to Float32Array at 16kHz mono.
1268
+ */
1269
+ extractWavSamples(data, audioFormat, numChannels, sampleRate, bitsPerSample) {
1270
+ let monoFloat32;
1271
+ if (audioFormat === 3 && bitsPerSample === 32) {
1272
+ const totalSamples = Math.floor(data.length / 4);
1273
+ const allSamples = new Float32Array(totalSamples);
1274
+ for (let i = 0; i < totalSamples; i++) {
1275
+ allSamples[i] = data.readFloatLE(i * 4);
1276
+ }
1277
+ monoFloat32 = this.mixToMono(allSamples, numChannels);
1278
+ } else if (audioFormat === 1 && bitsPerSample === 16) {
1279
+ const totalSamples = Math.floor(data.length / 2);
1280
+ const allSamples = new Float32Array(totalSamples);
1281
+ for (let i = 0; i < totalSamples; i++) {
1282
+ allSamples[i] = data.readInt16LE(i * 2) / 32768;
1283
+ }
1284
+ monoFloat32 = this.mixToMono(allSamples, numChannels);
1285
+ } else {
1286
+ throw new Error(
1287
+ `Unsupported WAV format: audioFormat=${audioFormat}, bitsPerSample=${bitsPerSample}. Expected PCM float32 (format=3, bits=32) or PCM int16 (format=1, bits=16).`
1288
+ );
1289
+ }
1290
+ if (sampleRate !== SAMPLE_RATE) {
1291
+ return this.resample(monoFloat32, sampleRate, SAMPLE_RATE);
1292
+ }
1293
+ return monoFloat32;
1294
+ }
1295
+ /**
1296
+ * Mix multi-channel audio down to mono by averaging channels.
1297
+ */
1298
+ mixToMono(samples, numChannels) {
1299
+ if (numChannels === 1) {
1300
+ return samples;
1301
+ }
1302
+ const monoLength = Math.floor(samples.length / numChannels);
1303
+ const mono = new Float32Array(monoLength);
1304
+ for (let i = 0; i < monoLength; i++) {
1305
+ let sum = 0;
1306
+ for (let ch = 0; ch < numChannels; ch++) {
1307
+ sum += samples[i * numChannels + ch];
1308
+ }
1309
+ mono[i] = sum / numChannels;
1310
+ }
1311
+ return mono;
1312
+ }
1313
+ /**
1314
+ * Simple linear resampling from one sample rate to another.
1315
+ */
1316
+ resample(samples, fromRate, toRate) {
1317
+ if (fromRate === toRate) {
1318
+ return samples;
1319
+ }
1320
+ const ratio = fromRate / toRate;
1321
+ const outputLength = Math.floor(samples.length / ratio);
1322
+ const output = new Float32Array(outputLength);
1323
+ for (let i = 0; i < outputLength; i++) {
1324
+ const srcIndex = i * ratio;
1325
+ const srcIndexFloor = Math.floor(srcIndex);
1326
+ const srcIndexCeil = Math.min(srcIndexFloor + 1, samples.length - 1);
1327
+ const frac = srcIndex - srcIndexFloor;
1328
+ output[i] = samples[srcIndexFloor] * (1 - frac) + samples[srcIndexCeil] * frac;
1329
+ }
1330
+ return output;
1331
+ }
1332
+ /**
1333
+ * Convert a non-WAV audio file to 16kHz mono Float32 WAV using ffmpeg,
1334
+ * then parse the resulting WAV.
1335
+ */
1336
+ async convertWithFfmpeg(audioPath) {
1337
+ const ffmpegAvailable = await this.isFfmpegAvailable();
1338
+ if (!ffmpegAvailable) {
1339
+ const installHint = process.platform === "darwin" ? "brew install ffmpeg" : process.platform === "win32" ? "winget install ffmpeg or download from https://ffmpeg.org" : "apt install ffmpeg (Debian/Ubuntu) or dnf install ffmpeg (Fedora)";
1340
+ throw new Error(
1341
+ `ffmpeg is not available on this system. ffmpeg is required to transcribe non-WAV audio files (webm, ogg, m4a). Install ffmpeg via: ${installHint}.`
1342
+ );
1343
+ }
1344
+ const tempFileName = `markupr-transcode-${randomUUID()}.wav`;
1345
+ const tempPath = join2(tmpdir(), tempFileName);
1346
+ try {
1347
+ this.log(`Converting ${audioPath} to WAV via ffmpeg...`);
1348
+ await execFileAsync("ffmpeg", [
1349
+ "-i",
1350
+ audioPath,
1351
+ "-ar",
1352
+ String(SAMPLE_RATE),
1353
+ "-ac",
1354
+ "1",
1355
+ "-f",
1356
+ "wav",
1357
+ "-acodec",
1358
+ "pcm_f32le",
1359
+ "-y",
1360
+ tempPath
1361
+ ], {
1362
+ env: { PATH: process.env.PATH, HOME: process.env.HOME, LANG: process.env.LANG, TMPDIR: process.env.TMPDIR }
1363
+ });
1364
+ await chmod(tempPath, 384).catch(() => {
1365
+ });
1366
+ this.log("ffmpeg conversion complete, parsing WAV...");
1367
+ return await this.parseWavFile(tempPath);
1368
+ } catch (error) {
1369
+ const msg = error instanceof Error ? error.message : String(error);
1370
+ throw new Error(`Failed to convert audio file with ffmpeg: ${msg}`);
1371
+ } finally {
1372
+ try {
1373
+ await unlink(tempPath);
1374
+ } catch {
1375
+ }
1376
+ }
1377
+ }
1378
+ /**
1379
+ * Process buffered audio through Whisper
1380
+ */
1381
+ async processBufferedAudio(force = false) {
1382
+ if (this.isProcessing) {
1383
+ return;
1384
+ }
1385
+ if (!force && this.totalBufferDuration < CHUNK_DURATION_MS) {
1386
+ return;
1387
+ }
1388
+ if (this.audioBuffer.length === 0) {
1389
+ return;
1390
+ }
1391
+ if (!this.whisperModule) {
1392
+ this.logError("Cannot process: Whisper module not loaded");
1393
+ return;
1394
+ }
1395
+ this.isProcessing = true;
1396
+ const processStartTime = this.bufferStartTime;
1397
+ try {
1398
+ const totalSamples = this.audioBuffer.reduce((sum, arr) => sum + arr.length, 0);
1399
+ const combinedAudio = new Float32Array(totalSamples);
1400
+ let offset = 0;
1401
+ for (const chunk of this.audioBuffer) {
1402
+ combinedAudio.set(chunk, offset);
1403
+ offset += chunk.length;
1404
+ }
1405
+ const processedDuration = this.totalBufferDuration;
1406
+ this.audioBuffer = [];
1407
+ this.totalBufferDuration = 0;
1408
+ this.totalBufferBytes = 0;
1409
+ this.bufferStartTime = Date.now();
1410
+ this.log(`Processing ${Math.round(processedDuration)}ms of audio...`);
1411
+ const result = await this.whisperModule.whisper(combinedAudio, {
1412
+ modelPath: this.config.modelPath,
1413
+ language: this.config.language,
1414
+ threads: this.config.threads,
1415
+ translate: this.config.translateToEnglish
1416
+ });
1417
+ if (result && result.length > 0) {
1418
+ for (const segment of result) {
1419
+ const transcriptResult = {
1420
+ text: segment.text.trim(),
1421
+ startTime: processStartTime / 1e3 + segment.start,
1422
+ endTime: processStartTime / 1e3 + segment.end,
1423
+ confidence: 0.9
1424
+ // Whisper doesn't provide confidence, use default
1425
+ };
1426
+ if (transcriptResult.text) {
1427
+ this.transcriptCallbacks.forEach((cb) => cb(transcriptResult));
1428
+ this.emit("transcript", transcriptResult);
1429
+ const preview = transcriptResult.text.length > 50 ? `${transcriptResult.text.substring(0, 50)}...` : transcriptResult.text;
1430
+ this.log(`Transcript: "${preview}"`);
1431
+ }
1432
+ }
1433
+ }
1434
+ } catch (error) {
1435
+ const transcriptionError = new Error(`Whisper transcription failed: ${error.message}`);
1436
+ this.errorCallbacks.forEach((cb) => cb(transcriptionError));
1437
+ this.emit("error", transcriptionError);
1438
+ this.logError("Transcription error", error);
1439
+ } finally {
1440
+ this.isProcessing = false;
1441
+ }
1442
+ }
1443
+ /**
1444
+ * Log helper
1445
+ */
1446
+ log(message) {
1447
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1448
+ console.log(`[WhisperService ${timestamp}] ${message}`);
1449
+ }
1450
+ /**
1451
+ * Error log helper
1452
+ */
1453
+ logError(message, error) {
1454
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1455
+ const errorStr = error instanceof Error ? error.message : String(error);
1456
+ console.error(`[WhisperService ${timestamp}] ERROR: ${message} - ${errorStr}`);
1457
+ }
1458
+ getRequiredMemoryBytes() {
1459
+ const modelName = basename(this.config.modelPath);
1460
+ return MODEL_MEMORY_REQUIREMENTS_BYTES[modelName] ?? MODEL_MEMORY_REQUIREMENTS_BYTES["ggml-small.bin"];
1461
+ }
1462
+ };
1463
+ var whisperService = new WhisperService();
1464
+
1465
+ // src/cli/CLIPipeline.ts
1466
+ var EXIT_SUCCESS = 0;
1467
+ var EXIT_USER_ERROR = 1;
1468
+ var EXIT_SYSTEM_ERROR = 2;
1469
+ var EXIT_SIGINT = 130;
1470
+ var CLIPipeline = class _CLIPipeline {
1471
+ options;
1472
+ log;
1473
+ progress;
1474
+ tempFiles = [];
1475
+ activeProcesses = /* @__PURE__ */ new Set();
1476
+ constructor(options, log, progress) {
1477
+ this.options = options;
1478
+ this.log = log;
1479
+ this.progress = progress ?? (() => {
1480
+ });
1481
+ }
1482
+ /**
1483
+ * Run the full pipeline: audio extraction -> transcription -> analysis ->
1484
+ * frame extraction -> markdown generation.
1485
+ */
1486
+ async run() {
1487
+ try {
1488
+ return await this.runPipeline();
1489
+ } finally {
1490
+ await this.cleanup();
1491
+ }
1492
+ }
1493
+ async runPipeline() {
1494
+ const startTime = Date.now();
1495
+ await this.validateVideoFile();
1496
+ if (!(this.options.audioPath && this.options.skipFrames)) {
1497
+ await this.checkFfmpegAvailable();
1498
+ }
1499
+ try {
1500
+ if (!existsSync3(this.options.outputDir)) {
1501
+ mkdirSync2(this.options.outputDir, { recursive: true });
1502
+ }
1503
+ } catch (error) {
1504
+ const code = error.code;
1505
+ if (code === "EACCES") {
1506
+ throw new CLIPipelineError(
1507
+ `Permission denied: cannot create output directory: ${this.options.outputDir}`,
1508
+ "user"
1509
+ );
1510
+ }
1511
+ throw new CLIPipelineError(
1512
+ `Cannot create output directory: ${this.options.outputDir} (${code})`,
1513
+ "system"
1514
+ );
1515
+ }
1516
+ this.progress("Extracting audio...");
1517
+ const audioPath = await this.resolveAudioPath();
1518
+ this.progress("Transcribing (this may take a while)...");
1519
+ const segments = await this.transcribe(audioPath);
1520
+ const analyzer = new TranscriptAnalyzer();
1521
+ const keyMoments = analyzer.analyze(segments);
1522
+ this.log(` Found ${keyMoments.length} key moment(s)`);
1523
+ let extractedFrames = [];
1524
+ if (!this.options.skipFrames) {
1525
+ this.progress("Extracting frames...");
1526
+ extractedFrames = await this.extractFrames(keyMoments, segments);
1527
+ } else {
1528
+ this.log(" Frame extraction skipped (--no-frames)");
1529
+ }
1530
+ this.progress("Generating report...");
1531
+ const result = {
1532
+ transcriptSegments: segments,
1533
+ extractedFrames,
1534
+ reportPath: this.options.outputDir
1535
+ };
1536
+ const generator = new MarkdownGeneratorImpl();
1537
+ const markdown = generator.generateFromPostProcess(result, this.options.outputDir);
1538
+ const outputFilename = this.generateOutputFilename();
1539
+ const outputPath = join3(this.options.outputDir, outputFilename);
1540
+ try {
1541
+ await writeFile(outputPath, markdown, "utf-8");
1542
+ } catch (error) {
1543
+ const code = error.code;
1544
+ throw new CLIPipelineError(
1545
+ `Failed to write output file: ${outputPath}
1546
+ Reason: ${code === "ENOSPC" ? "Disk is full" : error.message}`,
1547
+ "system"
1548
+ );
1549
+ }
1550
+ const durationSeconds = (Date.now() - startTime) / 1e3;
1551
+ return {
1552
+ outputPath,
1553
+ transcriptSegments: segments.length,
1554
+ extractedFrames: extractedFrames.length,
1555
+ durationSeconds
1556
+ };
1557
+ }
1558
+ /**
1559
+ * Abort the pipeline: kill active child processes and clean up temp files.
1560
+ */
1561
+ async abort() {
1562
+ for (const proc of this.activeProcesses) {
1563
+ proc.kill("SIGTERM");
1564
+ }
1565
+ this.activeProcesses.clear();
1566
+ await this.cleanup();
1567
+ }
1568
+ /**
1569
+ * Clean up temp files created during the pipeline run.
1570
+ */
1571
+ async cleanup() {
1572
+ for (const file of this.tempFiles) {
1573
+ try {
1574
+ await unlink2(file);
1575
+ } catch {
1576
+ }
1577
+ }
1578
+ this.tempFiles = [];
1579
+ }
1580
+ // ==========================================================================
1581
+ // Private Methods
1582
+ // ==========================================================================
1583
+ /**
1584
+ * Execute a child process while tracking it for cleanup on abort.
1585
+ */
1586
+ static SAFE_CHILD_ENV = {
1587
+ PATH: process.env.PATH,
1588
+ HOME: process.env.HOME || process.env.USERPROFILE,
1589
+ USERPROFILE: process.env.USERPROFILE,
1590
+ LANG: process.env.LANG,
1591
+ TMPDIR: process.env.TMPDIR || process.env.TEMP,
1592
+ TEMP: process.env.TEMP
1593
+ };
1594
+ execFileTracked(command, args) {
1595
+ return new Promise((resolve2, reject) => {
1596
+ const child = execFileCb2(command, args, { env: _CLIPipeline.SAFE_CHILD_ENV }, (error, stdout, stderr) => {
1597
+ this.activeProcesses.delete(child);
1598
+ if (error) reject(error);
1599
+ else resolve2({ stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "" });
1600
+ });
1601
+ this.activeProcesses.add(child);
1602
+ });
1603
+ }
1604
+ /**
1605
+ * Validate the video file is a real, non-empty file with a video stream.
1606
+ */
1607
+ async validateVideoFile() {
1608
+ const { videoPath } = this.options;
1609
+ let stats;
1610
+ try {
1611
+ stats = await stat(videoPath);
1612
+ } catch {
1613
+ throw new CLIPipelineError(`Video file not found: ${videoPath}`, "user");
1614
+ }
1615
+ if (!stats.isFile()) {
1616
+ throw new CLIPipelineError(`Not a regular file: ${videoPath}`, "user");
1617
+ }
1618
+ if (stats.size === 0) {
1619
+ throw new CLIPipelineError(`Video file is empty (0 bytes): ${videoPath}`, "user");
1620
+ }
1621
+ try {
1622
+ const { stdout } = await this.execFileTracked("ffprobe", [
1623
+ "-v",
1624
+ "error",
1625
+ "-select_streams",
1626
+ "v",
1627
+ "-show_entries",
1628
+ "stream=codec_type",
1629
+ "-of",
1630
+ "csv=p=0",
1631
+ videoPath
1632
+ ]);
1633
+ if (!stdout.trim().includes("video")) {
1634
+ throw new CLIPipelineError(
1635
+ `No video stream found in file: ${videoPath}`,
1636
+ "user"
1637
+ );
1638
+ }
1639
+ } catch (error) {
1640
+ if (error instanceof CLIPipelineError) throw error;
1641
+ throw new CLIPipelineError(
1642
+ `Cannot read video file (is ffprobe installed?): ${videoPath}`,
1643
+ "system"
1644
+ );
1645
+ }
1646
+ }
1647
+ /**
1648
+ * Check that ffmpeg is available on PATH.
1649
+ */
1650
+ async checkFfmpegAvailable() {
1651
+ try {
1652
+ await this.execFileTracked("ffmpeg", ["-version"]);
1653
+ } catch {
1654
+ const platform = process.platform;
1655
+ const installHint = platform === "darwin" ? "brew install ffmpeg" : platform === "win32" ? "winget install ffmpeg (or download from https://ffmpeg.org)" : "apt install ffmpeg (or your package manager)";
1656
+ throw new CLIPipelineError(
1657
+ `ffmpeg is required but not found on your system.
1658
+ Install via: ${installHint}
1659
+ Or provide a separate audio file with --audio <file> and --no-frames`,
1660
+ "system"
1661
+ );
1662
+ }
1663
+ }
1664
+ /**
1665
+ * Resolve the audio path. If no separate audio file was provided, probe
1666
+ * the video for an audio track and extract it to a temp WAV file.
1667
+ */
1668
+ async resolveAudioPath() {
1669
+ if (this.options.audioPath) {
1670
+ if (!existsSync3(this.options.audioPath)) {
1671
+ throw new CLIPipelineError(
1672
+ `Audio file not found: ${this.options.audioPath}`,
1673
+ "user"
1674
+ );
1675
+ }
1676
+ this.log(` Using provided audio: ${this.options.audioPath}`);
1677
+ return this.options.audioPath;
1678
+ }
1679
+ const hasAudio = await this.videoHasAudioTrack(this.options.videoPath);
1680
+ if (!hasAudio) {
1681
+ this.log(" No audio track found in video - transcription will be skipped");
1682
+ return null;
1683
+ }
1684
+ this.log(" Extracting audio from video...");
1685
+ const tempAudioPath = join3(tmpdir2(), `markupr-cli-audio-${randomUUID2()}.wav`);
1686
+ this.tempFiles.push(tempAudioPath);
1687
+ try {
1688
+ await this.execFileTracked("ffmpeg", [
1689
+ "-i",
1690
+ this.options.videoPath,
1691
+ "-vn",
1692
+ "-ar",
1693
+ "16000",
1694
+ "-ac",
1695
+ "1",
1696
+ "-f",
1697
+ "wav",
1698
+ "-acodec",
1699
+ "pcm_f32le",
1700
+ "-y",
1701
+ tempAudioPath
1702
+ ]);
1703
+ await chmod2(tempAudioPath, 384).catch(() => {
1704
+ });
1705
+ this.log(" Audio extraction complete");
1706
+ return tempAudioPath;
1707
+ } catch (error) {
1708
+ const msg = error instanceof Error ? error.message : String(error);
1709
+ this.log(` WARNING: Audio extraction failed: ${msg}`);
1710
+ return null;
1711
+ }
1712
+ }
1713
+ /**
1714
+ * Use ffprobe to check whether the video file contains an audio stream.
1715
+ */
1716
+ async videoHasAudioTrack(videoPath) {
1717
+ try {
1718
+ const { stdout } = await this.execFileTracked("ffprobe", [
1719
+ "-v",
1720
+ "error",
1721
+ "-select_streams",
1722
+ "a",
1723
+ "-show_entries",
1724
+ "stream=codec_type",
1725
+ "-of",
1726
+ "csv=p=0",
1727
+ videoPath
1728
+ ]);
1729
+ return stdout.trim().length > 0;
1730
+ } catch {
1731
+ return false;
1732
+ }
1733
+ }
1734
+ /**
1735
+ * Transcribe audio using WhisperService. Falls back gracefully if the
1736
+ * model is not available.
1737
+ */
1738
+ async transcribe(audioPath) {
1739
+ if (!audioPath) {
1740
+ this.log(" No audio available - skipping transcription");
1741
+ return [];
1742
+ }
1743
+ const whisper = new WhisperService(
1744
+ this.options.whisperModelPath ? { modelPath: this.options.whisperModelPath } : void 0
1745
+ );
1746
+ if (!whisper.isModelAvailable()) {
1747
+ const modelsDir = whisper.getModelsDirectory();
1748
+ this.log(` Whisper model not found at: ${whisper.getConfig().modelPath}`);
1749
+ this.log(` Models directory: ${modelsDir}`);
1750
+ this.log(" Transcription will be skipped. Download a model to enable transcription.");
1751
+ return [];
1752
+ }
1753
+ this.log(` Transcribing with Whisper (model: ${basename2(whisper.getConfig().modelPath)})...`);
1754
+ try {
1755
+ const results = await whisper.transcribeFile(audioPath, (percent) => {
1756
+ if (this.options.verbose) {
1757
+ process.stdout.write(`\r Transcription progress: ${percent}%`);
1758
+ }
1759
+ });
1760
+ if (this.options.verbose && results.length > 0) {
1761
+ process.stdout.write("\n");
1762
+ }
1763
+ const segments = results.map((r) => ({
1764
+ text: r.text,
1765
+ startTime: r.startTime,
1766
+ endTime: r.endTime,
1767
+ confidence: r.confidence
1768
+ }));
1769
+ this.log(` Transcription complete: ${segments.length} segment(s)`);
1770
+ return segments;
1771
+ } catch (error) {
1772
+ const msg = error instanceof Error ? error.message : String(error);
1773
+ this.log(` WARNING: Transcription failed: ${msg}`);
1774
+ return [];
1775
+ }
1776
+ }
1777
+ /**
1778
+ * Extract video frames at key moment timestamps.
1779
+ */
1780
+ async extractFrames(keyMoments, segments) {
1781
+ if (keyMoments.length === 0) {
1782
+ this.log(" No key moments found - skipping frame extraction");
1783
+ return [];
1784
+ }
1785
+ const extractor = new FrameExtractor();
1786
+ const available = await extractor.checkFfmpeg();
1787
+ if (!available) {
1788
+ this.log(" WARNING: ffmpeg not found - frame extraction skipped");
1789
+ this.log(" Install ffmpeg: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)");
1790
+ return [];
1791
+ }
1792
+ this.log(` Extracting ${keyMoments.length} frame(s)...`);
1793
+ const timestamps = keyMoments.map((m) => m.timestamp);
1794
+ const extractionResult = await extractor.extract({
1795
+ videoPath: this.options.videoPath,
1796
+ timestamps,
1797
+ outputDir: this.options.outputDir
1798
+ });
1799
+ const extractedFrames = extractionResult.frames.filter((f) => f.success).map((frame) => {
1800
+ const moment = keyMoments.find(
1801
+ (m) => Math.abs(m.timestamp - frame.timestamp) < 0.5
1802
+ );
1803
+ const closestSegment = this.findClosestSegment(frame.timestamp, segments);
1804
+ return {
1805
+ path: frame.path,
1806
+ timestamp: frame.timestamp,
1807
+ reason: moment?.reason ?? "Extracted frame",
1808
+ transcriptSegment: closestSegment
1809
+ };
1810
+ });
1811
+ this.log(` Extracted ${extractedFrames.length} frame(s)`);
1812
+ return extractedFrames;
1813
+ }
1814
+ /**
1815
+ * Find the transcript segment closest to a given timestamp.
1816
+ */
1817
+ findClosestSegment(timestamp, segments) {
1818
+ if (segments.length === 0) return void 0;
1819
+ for (const segment of segments) {
1820
+ if (timestamp >= segment.startTime && timestamp <= segment.endTime) {
1821
+ return segment;
1822
+ }
1823
+ }
1824
+ let closest = segments[0];
1825
+ let minDistance = Math.abs(timestamp - closest.startTime);
1826
+ for (let i = 1; i < segments.length; i++) {
1827
+ const distance = Math.abs(timestamp - segments[i].startTime);
1828
+ if (distance < minDistance) {
1829
+ minDistance = distance;
1830
+ closest = segments[i];
1831
+ }
1832
+ }
1833
+ return closest;
1834
+ }
1835
+ /**
1836
+ * Generate the output filename based on the video filename and current date (UTC).
1837
+ */
1838
+ generateOutputFilename() {
1839
+ const videoName = basename2(this.options.videoPath).replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-");
1840
+ const now = /* @__PURE__ */ new Date();
1841
+ const dateStr = [
1842
+ now.getUTCFullYear(),
1843
+ String(now.getUTCMonth() + 1).padStart(2, "0"),
1844
+ String(now.getUTCDate()).padStart(2, "0")
1845
+ ].join("");
1846
+ const timeStr = [
1847
+ String(now.getUTCHours()).padStart(2, "0"),
1848
+ String(now.getUTCMinutes()).padStart(2, "0"),
1849
+ String(now.getUTCSeconds()).padStart(2, "0")
1850
+ ].join("");
1851
+ return `${videoName}-feedback-${dateStr}-${timeStr}.md`;
1852
+ }
1853
+ };
1854
+ var CLIPipelineError = class extends Error {
1855
+ severity;
1856
+ constructor(message, severity) {
1857
+ super(message);
1858
+ this.name = "CLIPipelineError";
1859
+ this.severity = severity;
1860
+ }
1861
+ };
1862
+
1863
+ // src/cli/index.ts
1864
+ var VERSION = true ? "2.4.0" : "0.0.0-dev";
1865
+ var SYMBOLS = {
1866
+ check: "\u2714",
1867
+ // checkmark
1868
+ cross: "\u2718",
1869
+ // cross
1870
+ arrow: "\u2192",
1871
+ // right arrow
1872
+ bullet: "\u2022",
1873
+ // bullet
1874
+ ellipsis: "\u2026",
1875
+ // ellipsis
1876
+ line: "\u2500"
1877
+ // horizontal line
1878
+ };
1879
+ function banner() {
1880
+ console.log();
1881
+ console.log(` markupr v${VERSION} ${SYMBOLS.bullet} CLI Mode`);
1882
+ console.log(` ${SYMBOLS.line.repeat(40)}`);
1883
+ console.log();
1884
+ }
1885
+ function step(message) {
1886
+ console.log(` ${SYMBOLS.arrow} ${message}`);
1887
+ }
1888
+ function success(message) {
1889
+ console.log(` ${SYMBOLS.check} ${message}`);
1890
+ }
1891
+ function fail(message) {
1892
+ console.log(` ${SYMBOLS.cross} ${message}`);
1893
+ }
1894
+ var activePipeline = null;
1895
+ function setupSignalHandlers() {
1896
+ const handler = async () => {
1897
+ console.log("\n Interrupted \u2014 cleaning up...");
1898
+ if (activePipeline) {
1899
+ await activePipeline.abort();
1900
+ }
1901
+ process.exit(EXIT_SIGINT);
1902
+ };
1903
+ process.on("SIGINT", handler);
1904
+ process.on("SIGTERM", handler);
1905
+ }
1906
+ setupSignalHandlers();
1907
+ var program = new Command();
1908
+ program.name("markupr").description("Analyze screen recordings and generate AI-ready Markdown reports").version(VERSION, "-v, --version").showHelpAfterError("(use --help for available options)");
1909
+ program.command("analyze").description("Analyze a video recording and generate a structured feedback report").argument("<video-file>", "Path to the video file to analyze").option("--audio <file>", "Separate audio file (if not embedded in video)").option("--output <dir>", "Output directory", "./markupr-output").option("--whisper-model <path>", "Path to Whisper model file").option("--openai-key <key>", "OpenAI API key for cloud transcription (prefer OPENAI_API_KEY env var)").option("--no-frames", "Skip frame extraction").option("--verbose", "Verbose output", false).action(async (videoFile, options) => {
1910
+ banner();
1911
+ const videoPath = resolve(videoFile);
1912
+ const outputDir = resolve(options.output);
1913
+ const audioPath = options.audio ? resolve(options.audio) : void 0;
1914
+ const whisperModelPath = options.whisperModel ? resolve(options.whisperModel) : void 0;
1915
+ let openaiKey;
1916
+ if (options.openaiKey) {
1917
+ console.warn(" WARNING: Passing API keys via CLI args is insecure (visible in ps, shell history).");
1918
+ console.warn(" Use OPENAI_API_KEY env var instead.");
1919
+ console.warn();
1920
+ openaiKey = options.openaiKey;
1921
+ } else if (process.env.OPENAI_API_KEY) {
1922
+ openaiKey = process.env.OPENAI_API_KEY;
1923
+ }
1924
+ if (!existsSync4(videoPath)) {
1925
+ fail(`Video file not found: ${videoPath}`);
1926
+ process.exit(EXIT_USER_ERROR);
1927
+ }
1928
+ if (audioPath && !existsSync4(audioPath)) {
1929
+ fail(`Audio file not found: ${audioPath}`);
1930
+ process.exit(EXIT_USER_ERROR);
1931
+ }
1932
+ if (whisperModelPath && !existsSync4(whisperModelPath)) {
1933
+ fail(`Whisper model not found: ${whisperModelPath}`);
1934
+ process.exit(EXIT_USER_ERROR);
1935
+ }
1936
+ step(`Video: ${videoPath}`);
1937
+ if (audioPath) {
1938
+ step(`Audio: ${audioPath}`);
1939
+ }
1940
+ step(`Output: ${outputDir}`);
1941
+ console.log();
1942
+ const pipeline = new CLIPipeline(
1943
+ {
1944
+ videoPath,
1945
+ audioPath,
1946
+ outputDir,
1947
+ whisperModelPath,
1948
+ openaiKey,
1949
+ skipFrames: !options.frames,
1950
+ verbose: options.verbose
1951
+ },
1952
+ options.verbose ? step : () => {
1953
+ },
1954
+ step
1955
+ // progress — always visible
1956
+ );
1957
+ activePipeline = pipeline;
1958
+ try {
1959
+ step("Starting analysis pipeline...");
1960
+ console.log();
1961
+ const result = await pipeline.run();
1962
+ if (result.transcriptSegments === 0 && result.extractedFrames === 0) {
1963
+ console.log();
1964
+ fail("Analysis produced no output (no transcript, no frames).");
1965
+ console.log(" Possible causes:");
1966
+ console.log(" - Video has no audio track (provide --audio <file>)");
1967
+ console.log(" - Whisper model not installed (check --whisper-model)");
1968
+ console.log(" - ffmpeg not installed (brew install ffmpeg)");
1969
+ process.exit(EXIT_USER_ERROR);
1970
+ }
1971
+ console.log();
1972
+ success("Analysis complete!");
1973
+ console.log();
1974
+ console.log(` Transcript segments: ${result.transcriptSegments}`);
1975
+ console.log(` Extracted frames: ${result.extractedFrames}`);
1976
+ console.log(` Processing time: ${result.durationSeconds.toFixed(1)}s`);
1977
+ console.log();
1978
+ console.log(` Output: ${result.outputPath}`);
1979
+ console.log(`OUTPUT:${result.outputPath}`);
1980
+ console.log();
1981
+ } catch (error) {
1982
+ console.log();
1983
+ const message = error instanceof Error ? error.message : String(error);
1984
+ fail(`Analysis failed: ${message}`);
1985
+ if (options.verbose && error instanceof Error && error.stack) {
1986
+ console.log();
1987
+ console.log(error.stack);
1988
+ }
1989
+ const exitCode = error instanceof CLIPipelineError && error.severity === "user" ? EXIT_USER_ERROR : EXIT_SYSTEM_ERROR;
1990
+ process.exit(exitCode);
1991
+ } finally {
1992
+ activePipeline = null;
1993
+ }
1994
+ });
1995
+ if (process.argv.length <= 2) {
1996
+ banner();
1997
+ program.outputHelp();
1998
+ process.exit(EXIT_SUCCESS);
1999
+ }
2000
+ program.parse();