dashcam 1.0.1-beta.2 → 1.0.1-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +26 -20
- package/691cc08dc2fc02f59ae66f08 (1).mp4 +0 -0
- package/NPM_PUBLISH_FIX.md +104 -0
- package/SINGLE_FRAME_VIDEO_FIX.md +129 -0
- package/bin/dashcam-background.js +177 -0
- package/bin/dashcam.js +276 -132
- package/lib/ffmpeg.js +1 -2
- package/lib/logs/index.js +67 -11
- package/lib/processManager.js +104 -42
- package/lib/recorder.js +128 -27
- package/lib/tracking/FileTracker.js +7 -0
- package/lib/tracking/LogsTracker.js +21 -7
- package/lib/tracking/icons/index.js +3 -2
- package/lib/tracking/icons/linux.js +277 -0
- package/lib/uploader.js +10 -3
- package/package.json +4 -1
- package/scripts/sync-version.sh +48 -0
- package/test-short-recording.js +287 -0
- package/test_workflow.sh +99 -25
- package/.github/workflows/build.yml +0 -103
- package/.github/workflows/release.yml +0 -107
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { logger } from "../../logger.js";
|
|
2
|
+
import { execa } from "execa";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Find icon for a Linux application using various strategies
|
|
9
|
+
*/
|
|
10
|
+
const findLinuxIcon = async (appName) => {
|
|
11
|
+
// Strategy 1: Look for .desktop file
|
|
12
|
+
const desktopFile = await findDesktopFile(appName);
|
|
13
|
+
if (desktopFile) {
|
|
14
|
+
const iconName = await extractIconFromDesktop(desktopFile);
|
|
15
|
+
if (iconName) {
|
|
16
|
+
const iconPath = await findIconInTheme(iconName);
|
|
17
|
+
if (iconPath) {
|
|
18
|
+
logger.debug("Found icon via .desktop file", { appName, iconPath });
|
|
19
|
+
return iconPath;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Strategy 2: Try to find icon directly in icon themes
|
|
25
|
+
const iconPath = await findIconInTheme(appName);
|
|
26
|
+
if (iconPath) {
|
|
27
|
+
logger.debug("Found icon in theme", { appName, iconPath });
|
|
28
|
+
return iconPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Strategy 3: Common application paths
|
|
32
|
+
const commonPaths = [
|
|
33
|
+
`/usr/share/pixmaps/${appName}.png`,
|
|
34
|
+
`/usr/share/pixmaps/${appName}.svg`,
|
|
35
|
+
`/usr/share/icons/hicolor/48x48/apps/${appName}.png`,
|
|
36
|
+
`/usr/share/icons/hicolor/scalable/apps/${appName}.svg`,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const iconPath of commonPaths) {
|
|
40
|
+
if (fs.existsSync(iconPath)) {
|
|
41
|
+
logger.debug("Found icon in common path", { appName, iconPath });
|
|
42
|
+
return iconPath;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger.debug("No icon found for Linux app", { appName });
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find .desktop file for an application
|
|
52
|
+
*/
|
|
53
|
+
const findDesktopFile = async (appName) => {
|
|
54
|
+
const desktopDirs = [
|
|
55
|
+
"/usr/share/applications",
|
|
56
|
+
"/usr/local/share/applications",
|
|
57
|
+
path.join(os.homedir(), ".local/share/applications"),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// Try exact match first
|
|
61
|
+
for (const dir of desktopDirs) {
|
|
62
|
+
const desktopFile = path.join(dir, `${appName}.desktop`);
|
|
63
|
+
if (fs.existsSync(desktopFile)) {
|
|
64
|
+
return desktopFile;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Try case-insensitive search
|
|
69
|
+
for (const dir of desktopDirs) {
|
|
70
|
+
try {
|
|
71
|
+
if (!fs.existsSync(dir)) continue;
|
|
72
|
+
|
|
73
|
+
const files = fs.readdirSync(dir);
|
|
74
|
+
const match = files.find(
|
|
75
|
+
(f) => f.toLowerCase() === `${appName.toLowerCase()}.desktop`
|
|
76
|
+
);
|
|
77
|
+
if (match) {
|
|
78
|
+
return path.join(dir, match);
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.debug("Error reading desktop directory", { dir, error: error.message });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract icon name from .desktop file
|
|
90
|
+
*/
|
|
91
|
+
const extractIconFromDesktop = async (desktopFilePath) => {
|
|
92
|
+
try {
|
|
93
|
+
const content = fs.readFileSync(desktopFilePath, "utf8");
|
|
94
|
+
const iconMatch = content.match(/^Icon=(.+)$/m);
|
|
95
|
+
if (iconMatch) {
|
|
96
|
+
return iconMatch[1].trim();
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
logger.debug("Error reading desktop file", {
|
|
100
|
+
desktopFilePath,
|
|
101
|
+
error: error.message
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Find icon in XDG icon themes
|
|
109
|
+
*/
|
|
110
|
+
const findIconInTheme = async (iconName) => {
|
|
111
|
+
// Common icon theme locations and sizes
|
|
112
|
+
const iconThemes = ["hicolor", "gnome", "Adwaita", "breeze", "oxygen"];
|
|
113
|
+
const iconSizes = ["48x48", "64x64", "scalable", "128x128", "256x256"];
|
|
114
|
+
const iconFormats = ["png", "svg", "xpm"];
|
|
115
|
+
|
|
116
|
+
const searchPaths = [
|
|
117
|
+
"/usr/share/icons",
|
|
118
|
+
"/usr/local/share/icons",
|
|
119
|
+
path.join(os.homedir(), ".local/share/icons"),
|
|
120
|
+
path.join(os.homedir(), ".icons"),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
for (const basePath of searchPaths) {
|
|
124
|
+
if (!fs.existsSync(basePath)) continue;
|
|
125
|
+
|
|
126
|
+
for (const theme of iconThemes) {
|
|
127
|
+
const themePath = path.join(basePath, theme);
|
|
128
|
+
if (!fs.existsSync(themePath)) continue;
|
|
129
|
+
|
|
130
|
+
for (const size of iconSizes) {
|
|
131
|
+
const sizePath = path.join(themePath, size, "apps");
|
|
132
|
+
if (!fs.existsSync(sizePath)) continue;
|
|
133
|
+
|
|
134
|
+
for (const format of iconFormats) {
|
|
135
|
+
const iconPath = path.join(sizePath, `${iconName}.${format}`);
|
|
136
|
+
if (fs.existsSync(iconPath)) {
|
|
137
|
+
return iconPath;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Convert image to PNG if needed
|
|
149
|
+
*/
|
|
150
|
+
const convertToPng = async (iconPath) => {
|
|
151
|
+
const ext = path.extname(iconPath).toLowerCase();
|
|
152
|
+
|
|
153
|
+
// If already PNG, read and return
|
|
154
|
+
if (ext === ".png") {
|
|
155
|
+
return fs.readFileSync(iconPath);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// For SVG, try to convert using ImageMagick or rsvg-convert
|
|
159
|
+
if (ext === ".svg") {
|
|
160
|
+
const tmpPngPath = path.join(os.tmpdir(), `icon-${Date.now()}.png`);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// Try rsvg-convert first (commonly available on Linux)
|
|
164
|
+
await execa("rsvg-convert", [
|
|
165
|
+
"-w", "48",
|
|
166
|
+
"-h", "48",
|
|
167
|
+
"-o", tmpPngPath,
|
|
168
|
+
iconPath
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
const buffer = fs.readFileSync(tmpPngPath);
|
|
172
|
+
fs.unlinkSync(tmpPngPath);
|
|
173
|
+
return buffer;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
logger.debug("rsvg-convert failed, trying ImageMagick", { error: error.message });
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
// Fallback to ImageMagick convert
|
|
179
|
+
await execa("convert", [
|
|
180
|
+
"-background", "none",
|
|
181
|
+
"-resize", "48x48",
|
|
182
|
+
iconPath,
|
|
183
|
+
tmpPngPath
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
const buffer = fs.readFileSync(tmpPngPath);
|
|
187
|
+
fs.unlinkSync(tmpPngPath);
|
|
188
|
+
return buffer;
|
|
189
|
+
} catch (convertError) {
|
|
190
|
+
logger.debug("ImageMagick convert failed", { error: convertError.message });
|
|
191
|
+
|
|
192
|
+
// Clean up temp file if it exists
|
|
193
|
+
if (fs.existsSync(tmpPngPath)) {
|
|
194
|
+
fs.unlinkSync(tmpPngPath);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// For XPM, try ImageMagick
|
|
203
|
+
if (ext === ".xpm") {
|
|
204
|
+
const tmpPngPath = path.join(os.tmpdir(), `icon-${Date.now()}.png`);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await execa("convert", [
|
|
208
|
+
"-background", "none",
|
|
209
|
+
"-resize", "48x48",
|
|
210
|
+
iconPath,
|
|
211
|
+
tmpPngPath
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const buffer = fs.readFileSync(tmpPngPath);
|
|
215
|
+
fs.unlinkSync(tmpPngPath);
|
|
216
|
+
return buffer;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
logger.debug("Failed to convert XPM to PNG", { error: error.message });
|
|
219
|
+
|
|
220
|
+
// Clean up temp file if it exists
|
|
221
|
+
if (fs.existsSync(tmpPngPath)) {
|
|
222
|
+
fs.unlinkSync(tmpPngPath);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
logger.debug("Unsupported icon format", { ext, iconPath });
|
|
230
|
+
return null;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get icon as buffer for Linux application
|
|
235
|
+
* @param {string} appPath - Path to the application or process name
|
|
236
|
+
*/
|
|
237
|
+
const getIconAsBuffer = async (appPath) => {
|
|
238
|
+
try {
|
|
239
|
+
// Extract app name from path
|
|
240
|
+
let appName = path.basename(appPath);
|
|
241
|
+
|
|
242
|
+
// Remove common extensions
|
|
243
|
+
appName = appName.replace(/\.(exe|bin|sh|py|js)$/i, "");
|
|
244
|
+
|
|
245
|
+
logger.debug("Extracting icon for Linux app", { appName, appPath });
|
|
246
|
+
|
|
247
|
+
// Find the icon file
|
|
248
|
+
const iconPath = await findLinuxIcon(appName);
|
|
249
|
+
if (!iconPath) {
|
|
250
|
+
logger.debug("No icon found for Linux app", { appName });
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Convert to PNG if needed
|
|
255
|
+
const buffer = await convertToPng(iconPath);
|
|
256
|
+
if (!buffer) {
|
|
257
|
+
logger.debug("Failed to convert icon to PNG", { iconPath });
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
logger.debug("Successfully extracted Linux icon", {
|
|
262
|
+
appName,
|
|
263
|
+
iconPath,
|
|
264
|
+
bufferSize: buffer.length,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return { extension: "png", buffer };
|
|
268
|
+
} catch (error) {
|
|
269
|
+
logger.warn("Failed to extract Linux icon", {
|
|
270
|
+
appPath,
|
|
271
|
+
error: error.message,
|
|
272
|
+
});
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export { getIconAsBuffer };
|
package/lib/uploader.js
CHANGED
|
@@ -371,8 +371,12 @@ export async function upload(filePath, metadata = {}) {
|
|
|
371
371
|
for (const logStatus of trimmedLogs) {
|
|
372
372
|
if (logStatus.count > 0 && logStatus.trimmedFileLocation && fs.existsSync(logStatus.trimmedFileLocation)) {
|
|
373
373
|
try {
|
|
374
|
+
// Use the name from the status, or a default descriptive name
|
|
375
|
+
// The name is what shows in the "App" dropdown, not the file path
|
|
376
|
+
let logName = logStatus.name || 'File Logs';
|
|
377
|
+
|
|
374
378
|
logger.debug('Creating log STS credentials', {
|
|
375
|
-
name:
|
|
379
|
+
name: logName,
|
|
376
380
|
type: logStatus.type,
|
|
377
381
|
count: logStatus.count
|
|
378
382
|
});
|
|
@@ -380,7 +384,7 @@ export async function upload(filePath, metadata = {}) {
|
|
|
380
384
|
const logSts = await auth.createLogSts(
|
|
381
385
|
newReplay.replay.id,
|
|
382
386
|
logStatus.id || `log-${Date.now()}`,
|
|
383
|
-
|
|
387
|
+
logName,
|
|
384
388
|
logStatus.type || 'application'
|
|
385
389
|
);
|
|
386
390
|
|
|
@@ -440,9 +444,12 @@ export async function upload(filePath, metadata = {}) {
|
|
|
440
444
|
});
|
|
441
445
|
|
|
442
446
|
logExit();
|
|
447
|
+
|
|
448
|
+
const shareLink = newReplay.replay.shareLink;
|
|
449
|
+
|
|
443
450
|
return {
|
|
444
451
|
replay: newReplay.replay,
|
|
445
|
-
shareLink:
|
|
452
|
+
shareLink: shareLink
|
|
446
453
|
};
|
|
447
454
|
}
|
|
448
455
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dashcam",
|
|
3
|
-
"version": "1.0.1-beta.
|
|
3
|
+
"version": "1.0.1-beta.21",
|
|
4
4
|
"description": "Minimal CLI version of Dashcam desktop app",
|
|
5
5
|
"main": "bin/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -55,6 +55,9 @@
|
|
|
55
55
|
"@yao-pkg/pkg": "^6.10.1",
|
|
56
56
|
"esbuild": "^0.19.0"
|
|
57
57
|
},
|
|
58
|
+
"overrides": {
|
|
59
|
+
"@mapbox/node-pre-gyp": "^2.0.0"
|
|
60
|
+
},
|
|
58
61
|
"type": "module",
|
|
59
62
|
"engines": {
|
|
60
63
|
"node": ">=20.0.0"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Sync local version with npm registry
|
|
4
|
+
# This fixes version drift when publishes fail
|
|
5
|
+
|
|
6
|
+
echo "🔍 Checking version sync..."
|
|
7
|
+
|
|
8
|
+
# Get current version in package.json
|
|
9
|
+
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
|
10
|
+
echo "📦 Local version: $LOCAL_VERSION"
|
|
11
|
+
|
|
12
|
+
# Get current beta version from npm
|
|
13
|
+
NPM_BETA_VERSION=$(npm view dashcam dist-tags.beta 2>/dev/null)
|
|
14
|
+
if [ -z "$NPM_BETA_VERSION" ]; then
|
|
15
|
+
echo "⚠️ No beta tag found on npm, checking latest version..."
|
|
16
|
+
NPM_BETA_VERSION=$(npm view dashcam versions --json | jq -r '.[] | select(contains("beta"))' | tail -1)
|
|
17
|
+
fi
|
|
18
|
+
echo "📡 NPM beta version: $NPM_BETA_VERSION"
|
|
19
|
+
|
|
20
|
+
# Get all local git tags
|
|
21
|
+
echo ""
|
|
22
|
+
echo "🏷️ Local git tags:"
|
|
23
|
+
git tag | grep beta | tail -5
|
|
24
|
+
|
|
25
|
+
echo ""
|
|
26
|
+
echo "📡 NPM published versions:"
|
|
27
|
+
npm view dashcam versions --json | jq -r '.[] | select(contains("beta"))' | tail -5
|
|
28
|
+
|
|
29
|
+
echo ""
|
|
30
|
+
if [ "$LOCAL_VERSION" != "$NPM_BETA_VERSION" ]; then
|
|
31
|
+
echo "⚠️ Version mismatch detected!"
|
|
32
|
+
echo " Local: $LOCAL_VERSION"
|
|
33
|
+
echo " NPM: $NPM_BETA_VERSION"
|
|
34
|
+
echo ""
|
|
35
|
+
read -p "Do you want to sync package.json to $NPM_BETA_VERSION? (y/n) " -n 1 -r
|
|
36
|
+
echo
|
|
37
|
+
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
38
|
+
npm version $NPM_BETA_VERSION --no-git-tag-version --allow-same-version
|
|
39
|
+
echo "✅ Synced package.json to $NPM_BETA_VERSION"
|
|
40
|
+
echo ""
|
|
41
|
+
echo "⚠️ Note: You may have unpublished git tags. To clean them up:"
|
|
42
|
+
echo " git tag | grep beta | tail -10 # Review tags"
|
|
43
|
+
echo " git tag -d v1.0.1-beta.XX # Delete unpublished tags"
|
|
44
|
+
echo " git push origin :refs/tags/v1.0.1-beta.XX # Delete remote tags"
|
|
45
|
+
fi
|
|
46
|
+
else
|
|
47
|
+
echo "✅ Versions are in sync!"
|
|
48
|
+
fi
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test script for analyzing short recording issues
|
|
5
|
+
*
|
|
6
|
+
* This tests whether very short recordings produce valid multi-frame videos
|
|
7
|
+
* with properly finalized WebM container metadata.
|
|
8
|
+
*
|
|
9
|
+
* Known issue: If ffmpeg/VP9 encoder is killed too quickly, the WebM container
|
|
10
|
+
* metadata (especially duration) may be incomplete, causing playback issues.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node test-short-recording.js # Run recording tests
|
|
14
|
+
* node test-short-recording.js analyze <file> # Analyze existing video
|
|
15
|
+
* node test-short-recording.js fix <input> <output> # Fix broken video container
|
|
16
|
+
*
|
|
17
|
+
* Platform notes:
|
|
18
|
+
* - macOS: Uses AVFoundation for screen capture
|
|
19
|
+
* - Linux: Uses X11grab for screen capture
|
|
20
|
+
* - Windows: Uses gdigrab for screen capture
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { startRecording, stopRecording, fixVideoContainer } from './lib/recorder.js';
|
|
24
|
+
import { execa } from 'execa';
|
|
25
|
+
import { getFfprobePath } from './lib/binaries.js';
|
|
26
|
+
import fs from 'fs';
|
|
27
|
+
import path from 'path';
|
|
28
|
+
import os from 'os';
|
|
29
|
+
|
|
30
|
+
async function analyzeVideo(videoPath) {
|
|
31
|
+
const ffprobePath = await getFfprobePath();
|
|
32
|
+
|
|
33
|
+
console.log(`\n📊 Analyzing video: ${videoPath}`);
|
|
34
|
+
console.log('─'.repeat(80));
|
|
35
|
+
|
|
36
|
+
// Check if file exists
|
|
37
|
+
if (!fs.existsSync(videoPath)) {
|
|
38
|
+
console.error(`❌ Video file does not exist: ${videoPath}`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const stats = fs.statSync(videoPath);
|
|
43
|
+
console.log(`📁 File size: ${(stats.size / 1024).toFixed(2)} KB`);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// Get basic format info
|
|
47
|
+
const formatResult = await execa(ffprobePath, [
|
|
48
|
+
'-v', 'error',
|
|
49
|
+
'-show_entries', 'format=duration,size,bit_rate',
|
|
50
|
+
'-of', 'json',
|
|
51
|
+
videoPath
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const formatData = JSON.parse(formatResult.stdout);
|
|
55
|
+
console.log(`⏱️ Duration: ${formatData.format.duration || 'unknown'}s`);
|
|
56
|
+
console.log(`📊 Bit rate: ${formatData.format.bit_rate || 'unknown'} bits/s`);
|
|
57
|
+
|
|
58
|
+
// Get stream info
|
|
59
|
+
const streamResult = await execa(ffprobePath, [
|
|
60
|
+
'-v', 'error',
|
|
61
|
+
'-show_entries', 'stream=codec_name,width,height,r_frame_rate,duration',
|
|
62
|
+
'-of', 'json',
|
|
63
|
+
videoPath
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const streamData = JSON.parse(streamResult.stdout);
|
|
67
|
+
const videoStream = streamData.streams.find(s => s.codec_name);
|
|
68
|
+
|
|
69
|
+
if (videoStream) {
|
|
70
|
+
console.log(`🎥 Codec: ${videoStream.codec_name}`);
|
|
71
|
+
console.log(`📐 Resolution: ${videoStream.width}x${videoStream.height}`);
|
|
72
|
+
console.log(`🎞️ Frame rate: ${videoStream.r_frame_rate}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Count actual frames
|
|
76
|
+
const frameResult = await execa(ffprobePath, [
|
|
77
|
+
'-v', 'error',
|
|
78
|
+
'-count_frames',
|
|
79
|
+
'-select_streams', 'v:0',
|
|
80
|
+
'-show_entries', 'stream=nb_read_frames',
|
|
81
|
+
'-of', 'default=nokey=1:noprint_wrappers=1',
|
|
82
|
+
videoPath
|
|
83
|
+
], { reject: false });
|
|
84
|
+
|
|
85
|
+
const frameCount = parseInt(frameResult.stdout.trim());
|
|
86
|
+
console.log(`🖼️ Frame count: ${frameCount || 'unknown'}`);
|
|
87
|
+
|
|
88
|
+
if (frameResult.stderr) {
|
|
89
|
+
console.log(`⚠️ FFprobe warnings: ${frameResult.stderr.trim()}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check if duration is available in container
|
|
93
|
+
const hasDuration = formatData.format.duration && !isNaN(parseFloat(formatData.format.duration));
|
|
94
|
+
|
|
95
|
+
// Determine if this is a single-frame video issue
|
|
96
|
+
const isSingleFrame = frameCount === 1;
|
|
97
|
+
const hasEncodingIssues = frameResult.stderr.includes('File ended prematurely');
|
|
98
|
+
const hasMissingMetadata = !hasDuration;
|
|
99
|
+
|
|
100
|
+
console.log('\n📋 Analysis Result:');
|
|
101
|
+
console.log(` Single frame: ${isSingleFrame ? '❌ YES (BUG!)' : '✅ NO'}`);
|
|
102
|
+
console.log(` Encoding issues: ${hasEncodingIssues ? '⚠️ YES' : '✅ NO'}`);
|
|
103
|
+
console.log(` Missing metadata: ${hasMissingMetadata ? '⚠️ YES (container incomplete)' : '✅ NO'}`);
|
|
104
|
+
console.log(` Platform: ${os.platform()}`);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
exists: true,
|
|
108
|
+
size: stats.size,
|
|
109
|
+
duration: parseFloat(formatData.format.duration),
|
|
110
|
+
frameCount,
|
|
111
|
+
codec: videoStream?.codec_name,
|
|
112
|
+
resolution: videoStream ? `${videoStream.width}x${videoStream.height}` : 'unknown',
|
|
113
|
+
isSingleFrame,
|
|
114
|
+
hasEncodingIssues,
|
|
115
|
+
hasMissingMetadata,
|
|
116
|
+
platform: os.platform()
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(`❌ Error analyzing video: ${error.message}`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function testShortRecording(duration = 3000) {
|
|
126
|
+
console.log(`\n🎬 Testing ${duration}ms recording...`);
|
|
127
|
+
console.log('═'.repeat(80));
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// Start recording
|
|
131
|
+
console.log('▶️ Starting recording...');
|
|
132
|
+
const { outputPath, startTime } = await startRecording({
|
|
133
|
+
fps: 30,
|
|
134
|
+
includeAudio: false
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
console.log(`✅ Recording started at: ${outputPath}`);
|
|
138
|
+
|
|
139
|
+
// Wait for specified duration
|
|
140
|
+
console.log(`⏳ Recording for ${duration}ms...`);
|
|
141
|
+
await new Promise(resolve => setTimeout(resolve, duration));
|
|
142
|
+
|
|
143
|
+
// Stop recording
|
|
144
|
+
console.log('⏹️ Stopping recording...');
|
|
145
|
+
const result = await stopRecording();
|
|
146
|
+
|
|
147
|
+
console.log(`✅ Recording stopped`);
|
|
148
|
+
console.log(` Duration: ${result.duration}ms`);
|
|
149
|
+
console.log(` File: ${result.outputPath}`);
|
|
150
|
+
|
|
151
|
+
// Analyze the output
|
|
152
|
+
await analyzeVideo(result.outputPath);
|
|
153
|
+
|
|
154
|
+
return result;
|
|
155
|
+
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(`❌ Test failed: ${error.message}`);
|
|
158
|
+
console.error(error.stack);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function testExistingVideo(videoPath) {
|
|
164
|
+
console.log('\n🔍 Testing existing video...');
|
|
165
|
+
console.log('═'.repeat(80));
|
|
166
|
+
|
|
167
|
+
return await analyzeVideo(videoPath);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Main test runner
|
|
171
|
+
async function main() {
|
|
172
|
+
const args = process.argv.slice(2);
|
|
173
|
+
|
|
174
|
+
console.log('\n🧪 Short Recording Test Suite');
|
|
175
|
+
console.log('═'.repeat(80));
|
|
176
|
+
console.log(`Platform: ${os.platform()}`);
|
|
177
|
+
console.log(`Architecture: ${os.arch()}`);
|
|
178
|
+
console.log(`Node version: ${process.version}`);
|
|
179
|
+
|
|
180
|
+
if (args[0] === 'analyze' && args[1]) {
|
|
181
|
+
// Analyze existing video
|
|
182
|
+
const videoPath = path.resolve(args[1]);
|
|
183
|
+
const result = await testExistingVideo(videoPath);
|
|
184
|
+
|
|
185
|
+
if (result?.isSingleFrame) {
|
|
186
|
+
console.log('\n❌ SINGLE-FRAME VIDEO DETECTED!');
|
|
187
|
+
process.exit(1);
|
|
188
|
+
} else if (result?.hasMissingMetadata) {
|
|
189
|
+
console.log('\n⚠️ WARNING: Video container metadata is incomplete!');
|
|
190
|
+
console.log(' This can cause playback issues in some players.');
|
|
191
|
+
console.log(' The video has frames but duration is not in the container.');
|
|
192
|
+
console.log('\n💡 Try fixing it with:');
|
|
193
|
+
console.log(` node test-short-recording.js fix ${args[1]} ${args[1].replace(/\.(webm|mp4)$/, '-fixed.$1')}`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
} else if (args[0] === 'fix' && args[1] && args[2]) {
|
|
197
|
+
// Fix existing broken video
|
|
198
|
+
const inputPath = path.resolve(args[1]);
|
|
199
|
+
const outputPath = path.resolve(args[2]);
|
|
200
|
+
|
|
201
|
+
console.log('\n🔧 Fixing video container...');
|
|
202
|
+
console.log('═'.repeat(80));
|
|
203
|
+
console.log(`Input: ${inputPath}`);
|
|
204
|
+
console.log(`Output: ${outputPath}`);
|
|
205
|
+
|
|
206
|
+
if (!fs.existsSync(inputPath)) {
|
|
207
|
+
console.error(`❌ Input file does not exist: ${inputPath}`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Analyze before
|
|
212
|
+
console.log('\n📊 BEFORE:');
|
|
213
|
+
const beforeResult = await analyzeVideo(inputPath);
|
|
214
|
+
|
|
215
|
+
// Fix the video
|
|
216
|
+
const fixSuccess = await fixVideoContainer(inputPath, outputPath);
|
|
217
|
+
|
|
218
|
+
if (!fixSuccess) {
|
|
219
|
+
console.error('\n❌ Failed to fix video!');
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Analyze after
|
|
224
|
+
console.log('\n📊 AFTER:');
|
|
225
|
+
const afterResult = await analyzeVideo(outputPath);
|
|
226
|
+
|
|
227
|
+
console.log('\n✅ Video fixed successfully!');
|
|
228
|
+
console.log(` Before: ${beforeResult?.hasMissingMetadata ? 'Missing metadata ⚠️' : 'Has metadata ✅'}`);
|
|
229
|
+
console.log(` After: ${afterResult?.hasMissingMetadata ? 'Missing metadata ⚠️' : 'Has metadata ✅'}`);
|
|
230
|
+
|
|
231
|
+
if (afterResult?.hasMissingMetadata) {
|
|
232
|
+
console.log('\n⚠️ Warning: Metadata still missing after fix. The source file may be corrupted.');
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
// Run recording tests with different durations
|
|
237
|
+
const testDurations = [1000, 2000, 3000, 5000];
|
|
238
|
+
const results = [];
|
|
239
|
+
|
|
240
|
+
for (const duration of testDurations) {
|
|
241
|
+
try {
|
|
242
|
+
const result = await testShortRecording(duration);
|
|
243
|
+
results.push({ duration, success: true, result });
|
|
244
|
+
|
|
245
|
+
// Clean up
|
|
246
|
+
try {
|
|
247
|
+
fs.unlinkSync(result.outputPath);
|
|
248
|
+
if (result.gifPath && fs.existsSync(result.gifPath)) {
|
|
249
|
+
fs.unlinkSync(result.gifPath);
|
|
250
|
+
}
|
|
251
|
+
if (result.snapshotPath && fs.existsSync(result.snapshotPath)) {
|
|
252
|
+
fs.unlinkSync(result.snapshotPath);
|
|
253
|
+
}
|
|
254
|
+
} catch (cleanupError) {
|
|
255
|
+
console.warn(`⚠️ Cleanup warning: ${cleanupError.message}`);
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
results.push({ duration, success: false, error: error.message });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Wait between tests
|
|
262
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Summary
|
|
266
|
+
console.log('\n\n📊 TEST SUMMARY');
|
|
267
|
+
console.log('═'.repeat(80));
|
|
268
|
+
|
|
269
|
+
for (const result of results) {
|
|
270
|
+
const status = result.success ? '✅' : '❌';
|
|
271
|
+
console.log(`${status} ${result.duration}ms recording: ${result.success ? 'PASSED' : result.error}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const allPassed = results.every(r => r.success);
|
|
275
|
+
if (!allPassed) {
|
|
276
|
+
console.log('\n❌ Some tests failed!');
|
|
277
|
+
process.exit(1);
|
|
278
|
+
} else {
|
|
279
|
+
console.log('\n✅ All tests passed!');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
main().catch(error => {
|
|
285
|
+
console.error('Fatal error:', error);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
});
|