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