@apocaliss92/scrypted-reolink-native 0.1.33 → 0.1.36
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/.vscode/settings.json +1 -1
- package/README.md +6 -4
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/baichuan-base.ts +4 -4
- package/src/common.ts +288 -83
- package/src/main.ts +9 -5
- package/src/nvr.ts +36 -29
- package/src/utils.ts +552 -117
package/src/utils.ts
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
import type { DeviceCapabilities, EnrichedRecordingFile, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
1
|
+
import type { DeviceCapabilities, EnrichedRecordingFile, ParsedRecordingFileName, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo, VodFile, VodSearchResponse } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
2
|
import sdk, { DeviceBase, HttpRequest, HttpResponse, MediaObject, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, VideoClip, VideoClips } from "@scrypted/sdk";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { Readable } from "stream";
|
|
5
|
+
import http from "http";
|
|
6
|
+
import https from "https";
|
|
4
7
|
import fs from "fs";
|
|
5
8
|
import path from "path";
|
|
6
9
|
import crypto from "crypto";
|
|
7
10
|
import { CommonCameraMixin } from "./common";
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize FFmpeg output or URLs to avoid leaking credentials
|
|
13
|
+
*/
|
|
14
|
+
export function sanitizeFfmpegOutput(text: string): string {
|
|
15
|
+
if (!text)
|
|
16
|
+
return text;
|
|
17
|
+
|
|
18
|
+
let sanitized = text;
|
|
19
|
+
|
|
20
|
+
// Remove user/password query parameters from URLs: ?user=xxx&password=yyy
|
|
21
|
+
sanitized = sanitized.replace(/(\buser=)[^&\s]*/gi, '$1***');
|
|
22
|
+
sanitized = sanitized.replace(/(\bpassword=)[^&\s]*/gi, '$1***');
|
|
23
|
+
|
|
24
|
+
// Remove credentials from URLs like rtmp://user:pass@host/...
|
|
25
|
+
sanitized = sanitized.replace(/(rtmp:\/\/)([^:@\/\s]+):([^@\/\s]+)@/gi, '$1$2:***@');
|
|
26
|
+
|
|
27
|
+
return sanitized;
|
|
28
|
+
}
|
|
29
|
+
|
|
8
30
|
|
|
9
31
|
/**
|
|
10
32
|
* Enumeration of operation types that may require specific channel assignments
|
|
@@ -173,58 +195,92 @@ export async function recordingFileToVideoClip(
|
|
|
173
195
|
let videoHref: string | undefined = providedVideoHref;
|
|
174
196
|
let thumbnailHref: string | undefined;
|
|
175
197
|
|
|
198
|
+
logger?.debug(`[recordingFileToVideoClip] URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
|
|
199
|
+
|
|
176
200
|
// If webhook is enabled, generate webhook URLs
|
|
177
201
|
if (useWebhook && plugin && deviceId) {
|
|
202
|
+
logger?.debug(`[recordingFileToVideoClip] Generating webhook URLs for fileId=${id}`);
|
|
178
203
|
try {
|
|
179
204
|
const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
|
|
180
205
|
deviceId,
|
|
181
206
|
fileId: id,
|
|
182
207
|
plugin,
|
|
208
|
+
logger,
|
|
183
209
|
});
|
|
184
210
|
videoHref = videoUrl;
|
|
185
211
|
thumbnailHref = thumbnailUrl;
|
|
212
|
+
logger?.debug(`[recordingFileToVideoClip] Webhook URLs generated successfully: videoHref="${videoHref}", thumbnailHref="${thumbnailHref}"`);
|
|
186
213
|
} catch (e) {
|
|
187
|
-
logger?.error(
|
|
214
|
+
logger?.error(`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`, e);
|
|
188
215
|
}
|
|
189
216
|
} else if (!videoHref && api) {
|
|
190
217
|
// Fallback to direct RTMP URL if webhook is not used
|
|
218
|
+
logger?.debug(`[recordingFileToVideoClip] Fetching RTMP playback URL for fileName=${rec.fileName}`);
|
|
191
219
|
try {
|
|
192
220
|
const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
|
|
193
221
|
fileName: rec.fileName,
|
|
194
222
|
});
|
|
195
223
|
videoHref = rtmpVodUrl;
|
|
224
|
+
logger?.debug(`[recordingFileToVideoClip] RTMP URL fetched successfully: videoHref="${videoHref}"`);
|
|
196
225
|
} catch (e) {
|
|
197
|
-
logger?.debug(
|
|
226
|
+
logger?.debug(`[recordingFileToVideoClip] Failed to build playback URL for recording fileName=${rec.fileName}:`, e);
|
|
198
227
|
}
|
|
228
|
+
} else {
|
|
229
|
+
logger?.debug(`[recordingFileToVideoClip] No URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
|
|
199
230
|
}
|
|
200
231
|
|
|
201
232
|
const description = ('name' in rec && typeof rec.name === 'string' && rec.name) ? rec.name : (rec.fileName ?? rec.id ?? '');
|
|
202
233
|
|
|
203
234
|
// Build detectionClasses from flags or recordType
|
|
204
|
-
const detectionClasses: string[] = [
|
|
235
|
+
const detectionClasses: string[] = [];
|
|
205
236
|
|
|
206
|
-
// Check for EnrichedRecordingFile flags
|
|
237
|
+
// Check for EnrichedRecordingFile flags first (most accurate)
|
|
238
|
+
let hasAnyDetection = false;
|
|
207
239
|
if ('hasPerson' in rec && rec.hasPerson) {
|
|
208
240
|
detectionClasses.push('Person');
|
|
241
|
+
hasAnyDetection = true;
|
|
209
242
|
}
|
|
210
243
|
if ('hasVehicle' in rec && rec.hasVehicle) {
|
|
211
244
|
detectionClasses.push('Vehicle');
|
|
245
|
+
hasAnyDetection = true;
|
|
212
246
|
}
|
|
213
247
|
if ('hasAnimal' in rec && rec.hasAnimal) {
|
|
214
248
|
detectionClasses.push('Animal');
|
|
249
|
+
hasAnyDetection = true;
|
|
215
250
|
}
|
|
216
251
|
if ('hasFace' in rec && rec.hasFace) {
|
|
217
252
|
detectionClasses.push('Face');
|
|
253
|
+
hasAnyDetection = true;
|
|
254
|
+
}
|
|
255
|
+
if ('hasMotion' in rec && rec.hasMotion) {
|
|
256
|
+
detectionClasses.push('Motion');
|
|
257
|
+
hasAnyDetection = true;
|
|
218
258
|
}
|
|
219
259
|
if ('hasDoorbell' in rec && rec.hasDoorbell) {
|
|
220
260
|
detectionClasses.push('Doorbell');
|
|
261
|
+
hasAnyDetection = true;
|
|
221
262
|
}
|
|
222
263
|
if ('hasPackage' in rec && rec.hasPackage) {
|
|
223
264
|
detectionClasses.push('Package');
|
|
265
|
+
hasAnyDetection = true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Log detection flags for debugging
|
|
269
|
+
if (logger) {
|
|
270
|
+
const flags = {
|
|
271
|
+
hasPerson: 'hasPerson' in rec ? rec.hasPerson : undefined,
|
|
272
|
+
hasVehicle: 'hasVehicle' in rec ? rec.hasVehicle : undefined,
|
|
273
|
+
hasAnimal: 'hasAnimal' in rec ? rec.hasAnimal : undefined,
|
|
274
|
+
hasFace: 'hasFace' in rec ? rec.hasFace : undefined,
|
|
275
|
+
hasMotion: 'hasMotion' in rec ? rec.hasMotion : undefined,
|
|
276
|
+
hasDoorbell: 'hasDoorbell' in rec ? rec.hasDoorbell : undefined,
|
|
277
|
+
hasPackage: 'hasPackage' in rec ? rec.hasPackage : undefined,
|
|
278
|
+
recordType: rec.recordType || 'none',
|
|
279
|
+
};
|
|
224
280
|
}
|
|
225
281
|
|
|
226
282
|
// Fallback: parse recordType string if flags are not available
|
|
227
|
-
if (
|
|
283
|
+
if (!hasAnyDetection && rec.recordType) {
|
|
228
284
|
const recordTypeLower = rec.recordType.toLowerCase();
|
|
229
285
|
if (recordTypeLower.includes('people') || recordTypeLower.includes('person')) {
|
|
230
286
|
detectionClasses.push('Person');
|
|
@@ -249,6 +305,19 @@ export async function recordingFileToVideoClip(
|
|
|
249
305
|
}
|
|
250
306
|
}
|
|
251
307
|
|
|
308
|
+
// Always include Motion if no other detections found
|
|
309
|
+
if (detectionClasses.length === 0) {
|
|
310
|
+
detectionClasses.push('Motion');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const resources = videoHref || thumbnailHref
|
|
314
|
+
? {
|
|
315
|
+
...(videoHref ? { video: { href: videoHref } } : {}),
|
|
316
|
+
...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
|
|
317
|
+
}
|
|
318
|
+
: undefined;
|
|
319
|
+
|
|
320
|
+
|
|
252
321
|
return {
|
|
253
322
|
id,
|
|
254
323
|
startTime: recStartMs,
|
|
@@ -256,12 +325,7 @@ export async function recordingFileToVideoClip(
|
|
|
256
325
|
event: rec.recordType,
|
|
257
326
|
description,
|
|
258
327
|
detectionClasses: detectionClasses.length > 0 ? detectionClasses : undefined,
|
|
259
|
-
resources
|
|
260
|
-
? {
|
|
261
|
-
...(videoHref ? { video: { href: videoHref } } : {}),
|
|
262
|
-
...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
|
|
263
|
-
}
|
|
264
|
-
: undefined,
|
|
328
|
+
resources,
|
|
265
329
|
};
|
|
266
330
|
}
|
|
267
331
|
|
|
@@ -272,17 +336,26 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
272
336
|
deviceId: string;
|
|
273
337
|
fileId: string;
|
|
274
338
|
plugin: ScryptedDeviceBase;
|
|
339
|
+
logger?: Console;
|
|
275
340
|
}): Promise<{ videoUrl: string; thumbnailUrl: string }> {
|
|
276
|
-
const { deviceId, fileId, plugin } = props;
|
|
341
|
+
const { deviceId, fileId, plugin, logger } = props;
|
|
342
|
+
const log = logger || plugin.console;
|
|
343
|
+
|
|
344
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
|
|
277
345
|
|
|
278
346
|
try {
|
|
279
347
|
let endpoint: string;
|
|
348
|
+
let endpointSource: 'cloud' | 'local';
|
|
280
349
|
try {
|
|
281
350
|
endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
|
|
351
|
+
endpointSource = 'cloud';
|
|
352
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
|
|
282
353
|
} catch (e) {
|
|
283
354
|
// Fallback to local endpoint if cloud is not available (e.g., not logged in)
|
|
284
|
-
|
|
355
|
+
log.debug?.(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e instanceof Error ? e.message : String(e)}`);
|
|
285
356
|
endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
|
|
357
|
+
endpointSource = 'local';
|
|
358
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
|
|
286
359
|
}
|
|
287
360
|
|
|
288
361
|
const encodedDeviceId = encodeURIComponent(deviceId);
|
|
@@ -296,7 +369,7 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
296
369
|
const queryParams = endpointUrl.search;
|
|
297
370
|
// Remove query parameters from the base endpoint URL
|
|
298
371
|
endpointUrl.search = '';
|
|
299
|
-
|
|
372
|
+
|
|
300
373
|
// Ensure endpoint has trailing slash
|
|
301
374
|
const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
|
|
302
375
|
|
|
@@ -306,7 +379,10 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
306
379
|
|
|
307
380
|
return { videoUrl, thumbnailUrl };
|
|
308
381
|
} catch (e) {
|
|
309
|
-
|
|
382
|
+
log.error?.(
|
|
383
|
+
`[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
|
|
384
|
+
e
|
|
385
|
+
);
|
|
310
386
|
throw e;
|
|
311
387
|
}
|
|
312
388
|
}
|
|
@@ -319,10 +395,13 @@ export async function extractThumbnailFromVideo(props: {
|
|
|
319
395
|
filePath?: string;
|
|
320
396
|
fileId: string;
|
|
321
397
|
deviceId: string;
|
|
322
|
-
logger
|
|
398
|
+
logger?: Console;
|
|
399
|
+
device?: CommonCameraMixin;
|
|
323
400
|
}): Promise<MediaObject> {
|
|
324
|
-
const { rtmpUrl, filePath, fileId, deviceId,
|
|
325
|
-
|
|
401
|
+
const { rtmpUrl, filePath, fileId, deviceId, device } = props;
|
|
402
|
+
// Use device logger if available, otherwise fallback to provided logger
|
|
403
|
+
const logger = device?.getBaichuanLogger?.() || props.logger || console;
|
|
404
|
+
|
|
326
405
|
// Use file path if available, otherwise use RTMP URL
|
|
327
406
|
const inputSource = filePath || rtmpUrl;
|
|
328
407
|
if (!inputSource) {
|
|
@@ -330,89 +409,17 @@ export async function extractThumbnailFromVideo(props: {
|
|
|
330
409
|
}
|
|
331
410
|
|
|
332
411
|
try {
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
'-vframes', '1', // Extract only 1 frame
|
|
341
|
-
'-q:v', '2', // High quality JPEG
|
|
342
|
-
'-f', 'image2', // Output format
|
|
343
|
-
'pipe:1', // Output to stdout
|
|
344
|
-
];
|
|
345
|
-
|
|
346
|
-
return new Promise<MediaObject>((resolve, reject) => {
|
|
347
|
-
const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
|
|
348
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
const chunks: Buffer[] = [];
|
|
352
|
-
let errorOutput = '';
|
|
353
|
-
|
|
354
|
-
ffmpeg.stdout.on('data', (chunk: Buffer) => {
|
|
355
|
-
chunks.push(chunk);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
ffmpeg.stderr.on('data', (chunk: Buffer) => {
|
|
359
|
-
errorOutput += chunk.toString();
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
let resolved = false;
|
|
363
|
-
|
|
364
|
-
ffmpeg.on('close', async (code) => {
|
|
365
|
-
if (resolved) return;
|
|
366
|
-
resolved = true;
|
|
367
|
-
|
|
368
|
-
if (code !== 0) {
|
|
369
|
-
logger.error(`[Thumbnail] Error: fileId=${fileId}`, new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
|
|
370
|
-
reject(new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
try {
|
|
375
|
-
const imageBuffer = Buffer.concat(chunks);
|
|
376
|
-
if (imageBuffer.length === 0) {
|
|
377
|
-
logger.error(`[Thumbnail] Error: fileId=${fileId}`, new Error('No image data received from ffmpeg'));
|
|
378
|
-
reject(new Error('No image data received from ffmpeg'));
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const mo = await sdk.mediaManager.createMediaObject(imageBuffer, 'image/jpeg');
|
|
383
|
-
logger.log(`[Thumbnail] Completed: fileId=${fileId}, size=${imageBuffer.length} bytes`);
|
|
384
|
-
resolve(mo);
|
|
385
|
-
} catch (e) {
|
|
386
|
-
logger.error(`[Thumbnail] Error: fileId=${fileId}`, e);
|
|
387
|
-
reject(e);
|
|
388
|
-
}
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
ffmpeg.on('error', (error) => {
|
|
392
|
-
if (resolved) return;
|
|
393
|
-
resolved = true;
|
|
394
|
-
logger.error(`[Thumbnail] Error: fileId=${fileId}`, error);
|
|
395
|
-
reject(error);
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
// Timeout after 30 seconds
|
|
399
|
-
const timeout = setTimeout(() => {
|
|
400
|
-
if (resolved) return;
|
|
401
|
-
resolved = true;
|
|
402
|
-
try {
|
|
403
|
-
ffmpeg.kill('SIGKILL');
|
|
404
|
-
} catch (e) {
|
|
405
|
-
// Ignore
|
|
406
|
-
}
|
|
407
|
-
reject(new Error('Thumbnail extraction timeout'));
|
|
408
|
-
}, 30000);
|
|
409
|
-
|
|
410
|
-
ffmpeg.on('close', () => {
|
|
411
|
-
clearTimeout(timeout);
|
|
412
|
-
});
|
|
412
|
+
// Use createFFmpegMediaObject which handles codec detection better
|
|
413
|
+
// For Download URLs from NVR, they might return only a short segment, so use 1 second instead of 5
|
|
414
|
+
const mo = await sdk.mediaManager.createFFmpegMediaObject({
|
|
415
|
+
inputArguments: [
|
|
416
|
+
'-ss', '00:00:01', // Seek to 1 second (safer for short segments from NVR Download URLs)
|
|
417
|
+
'-i', inputSource,
|
|
418
|
+
],
|
|
413
419
|
});
|
|
420
|
+
return mo;
|
|
414
421
|
} catch (e) {
|
|
415
|
-
|
|
422
|
+
// Error already logged in main.ts
|
|
416
423
|
throw e;
|
|
417
424
|
}
|
|
418
425
|
}
|
|
@@ -426,7 +433,7 @@ function getVideoClipCachePath(deviceId: string, fileId: string): string {
|
|
|
426
433
|
const hash = crypto.createHash('md5').update(fileId).digest('hex');
|
|
427
434
|
// Keep original extension if present, otherwise use .mp4
|
|
428
435
|
const ext = fileId.includes('.') ? path.extname(fileId) : '.mp4';
|
|
429
|
-
const cacheDir = path.join(pluginVolume, '
|
|
436
|
+
const cacheDir = path.join(pluginVolume, 'videoclips', deviceId);
|
|
430
437
|
return path.join(cacheDir, `${hash}${ext}`);
|
|
431
438
|
}
|
|
432
439
|
|
|
@@ -440,12 +447,14 @@ export async function handleVideoClipRequest(props: {
|
|
|
440
447
|
fileId: string;
|
|
441
448
|
request: HttpRequest;
|
|
442
449
|
response: HttpResponse;
|
|
443
|
-
logger
|
|
450
|
+
logger?: Console;
|
|
444
451
|
}): Promise<void> {
|
|
445
|
-
const { device, deviceId, fileId, request, response
|
|
452
|
+
const { device, deviceId, fileId, request, response } = props;
|
|
453
|
+
const logger = device.getBaichuanLogger?.() || props.logger || console;
|
|
446
454
|
|
|
447
455
|
// Check if file is cached
|
|
448
456
|
const cachePath = getVideoClipCachePath(deviceId, fileId);
|
|
457
|
+
const MIN_VIDEO_CACHE_BYTES = 16 * 1024; // 16KB, evita file quasi vuoti/corrotti
|
|
449
458
|
|
|
450
459
|
try {
|
|
451
460
|
// Check if cached file exists
|
|
@@ -453,6 +462,17 @@ export async function handleVideoClipRequest(props: {
|
|
|
453
462
|
const fileSize = stat.size;
|
|
454
463
|
const range = request.headers.range;
|
|
455
464
|
|
|
465
|
+
if (fileSize < MIN_VIDEO_CACHE_BYTES) {
|
|
466
|
+
logger.warn(`Cached video clip too small, deleting and reloading: fileId=${fileId}, size=${fileSize} bytes`);
|
|
467
|
+
try {
|
|
468
|
+
await fs.promises.unlink(cachePath);
|
|
469
|
+
} catch (unlinkErr) {
|
|
470
|
+
logger.warn(`Failed to delete small cached video clip: fileId=${fileId}`, unlinkErr);
|
|
471
|
+
}
|
|
472
|
+
// Force cache miss path below
|
|
473
|
+
throw new Error('Cached video too small, deleted');
|
|
474
|
+
}
|
|
475
|
+
|
|
456
476
|
logger.log(`Serving cached video clip: fileId=${fileId}, size=${fileSize}, range=${range}`);
|
|
457
477
|
|
|
458
478
|
if (range) {
|
|
@@ -493,32 +513,231 @@ export async function handleVideoClipRequest(props: {
|
|
|
493
513
|
}
|
|
494
514
|
} catch (e) {
|
|
495
515
|
// File not cached, need to proxy RTMP stream
|
|
496
|
-
logger.log(`
|
|
516
|
+
logger.log(`[VideoClip] Stream start: fileId=${fileId}`);
|
|
497
517
|
|
|
498
|
-
// Get RTMP URL
|
|
499
|
-
// Cast device to CommonCameraMixin to access API
|
|
518
|
+
// Get RTMP URL using the appropriate API (NVR or Baichuan)
|
|
500
519
|
let rtmpVodUrl: string | undefined;
|
|
501
520
|
try {
|
|
502
|
-
|
|
503
|
-
const result = await api.getRecordingPlaybackUrls({
|
|
504
|
-
fileName: fileId,
|
|
505
|
-
});
|
|
506
|
-
rtmpVodUrl = result.rtmpVodUrl;
|
|
521
|
+
rtmpVodUrl = await device.getVideoClipRtmpUrl(fileId);
|
|
507
522
|
} catch (e2) {
|
|
508
|
-
logger.error(`
|
|
523
|
+
logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e2);
|
|
509
524
|
response.send('Failed to get RTMP playback URL', { code: 500 });
|
|
510
525
|
return;
|
|
511
526
|
}
|
|
512
527
|
|
|
513
528
|
if (!rtmpVodUrl) {
|
|
514
|
-
logger.error(`
|
|
529
|
+
logger.error(`[VideoClip] Stream error: fileId=${fileId} - No URL found`);
|
|
515
530
|
response.send('No RTMP playback URL found for video', { code: 404 });
|
|
516
531
|
return;
|
|
517
532
|
}
|
|
518
533
|
|
|
519
|
-
//
|
|
534
|
+
// Check if URL is HTTP (Playback/Download) or RTMP
|
|
535
|
+
const isHttpUrl = rtmpVodUrl.startsWith('http://') || rtmpVodUrl.startsWith('https://');
|
|
536
|
+
|
|
537
|
+
if (isHttpUrl) {
|
|
538
|
+
// For HTTP URLs (Playback/Download from NVR), do direct HTTP proxy with ranged headers support
|
|
539
|
+
logger.debug(`Proxying HTTP URL directly: fileId=${fileId}, url=${rtmpVodUrl}`);
|
|
540
|
+
|
|
541
|
+
const sendVideo = async () => {
|
|
542
|
+
// Pre-fetch ffmpeg path in case we need it for FLV conversion
|
|
543
|
+
const ffmpegPathPromise = sdk.mediaManager.getFFmpegPath();
|
|
544
|
+
|
|
545
|
+
return new Promise<void>(async (resolve, reject) => {
|
|
546
|
+
const urlObj = new URL(rtmpVodUrl);
|
|
547
|
+
const httpModule = urlObj.protocol === 'https:' ? https : http;
|
|
548
|
+
|
|
549
|
+
// Filter and prepare headers (remove host, connection, etc. that shouldn't be forwarded)
|
|
550
|
+
const requestHeaders: Record<string, string> = {};
|
|
551
|
+
if (request.headers.range) {
|
|
552
|
+
requestHeaders['Range'] = request.headers.range;
|
|
553
|
+
}
|
|
554
|
+
// Add other headers that might be needed
|
|
555
|
+
if (request.headers['user-agent']) {
|
|
556
|
+
requestHeaders['User-Agent'] = request.headers['user-agent'];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const options = {
|
|
560
|
+
hostname: urlObj.hostname,
|
|
561
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
562
|
+
path: urlObj.pathname + urlObj.search,
|
|
563
|
+
method: 'GET',
|
|
564
|
+
headers: requestHeaders,
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
logger.debug(`Starting HTTP request: ${rtmpVodUrl}, headers: ${JSON.stringify(requestHeaders)}`);
|
|
568
|
+
|
|
569
|
+
httpModule.get(options, async (httpResponse) => {
|
|
570
|
+
if (httpResponse.statusCode && httpResponse.statusCode >= 400) {
|
|
571
|
+
logger.error(`HTTP error: status=${httpResponse.statusCode}, message=${httpResponse.statusMessage}`);
|
|
572
|
+
reject(new Error(`Error loading the video: ${httpResponse.statusCode} - ${httpResponse.statusMessage}`));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
let contentType = httpResponse.headers['content-type'] || 'video/mp4';
|
|
577
|
+
const contentLength = httpResponse.headers['content-length'];
|
|
578
|
+
const contentRange = httpResponse.headers['content-range'];
|
|
579
|
+
const acceptRanges = httpResponse.headers['accept-ranges'] || 'bytes';
|
|
580
|
+
|
|
581
|
+
// Check if we need to convert FLV to MP4
|
|
582
|
+
const isFlv = typeof contentType === 'string' && (contentType === 'video/x-flv' || contentType === 'video/flv');
|
|
583
|
+
|
|
584
|
+
if (isFlv) {
|
|
585
|
+
logger.debug(`Content-Type is FLV (${contentType}), will convert to MP4 using ffmpeg`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const responseHeaders: Record<string, string> = {
|
|
589
|
+
'Content-Type': typeof contentType === 'string' ? contentType : 'video/mp4',
|
|
590
|
+
'Accept-Ranges': typeof acceptRanges === 'string' ? acceptRanges : 'bytes',
|
|
591
|
+
'Cache-Control': 'no-cache',
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
if (contentLength) {
|
|
595
|
+
responseHeaders['Content-Length'] = typeof contentLength === 'string' ? contentLength : String(contentLength);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (contentRange) {
|
|
599
|
+
responseHeaders['Content-Range'] = typeof contentRange === 'string' ? contentRange : String(contentRange);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const statusCode = httpResponse.statusCode || 200;
|
|
603
|
+
|
|
604
|
+
logger.debug(`HTTP response received: status=${statusCode}, contentType=${contentType}, contentLength=${contentLength || 'unknown'}`);
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
if (isFlv) {
|
|
608
|
+
// Convert FLV to MP4 using ffmpeg
|
|
609
|
+
const ffmpegPath = await ffmpegPathPromise;
|
|
610
|
+
// Re-encode instead of copy because FLV codec might not be supported
|
|
611
|
+
const ffmpegArgs: string[] = [
|
|
612
|
+
'-i', 'pipe:0', // Read from stdin (httpResponse)
|
|
613
|
+
'-c:v', 'libx264', // Re-encode video to H.264
|
|
614
|
+
'-preset', 'ultrafast', // Fast encoding for streaming
|
|
615
|
+
'-tune', 'zerolatency', // Low latency
|
|
616
|
+
'-c:a', 'aac', // Re-encode audio to AAC
|
|
617
|
+
'-f', 'mp4',
|
|
618
|
+
'-movflags', 'frag_keyframe+empty_moov', // Enable streaming
|
|
619
|
+
'pipe:1', // Output to stdout
|
|
620
|
+
];
|
|
621
|
+
|
|
622
|
+
const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
|
|
623
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
let ffmpegError = '';
|
|
627
|
+
ffmpeg.stderr.on('data', (chunk: Buffer) => {
|
|
628
|
+
ffmpegError += chunk.toString();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// Pipe httpResponse to ffmpeg stdin
|
|
632
|
+
httpResponse.pipe(ffmpeg.stdin);
|
|
633
|
+
|
|
634
|
+
ffmpeg.stdin.on('error', (err) => {
|
|
635
|
+
// Ignore EPIPE errors when ffmpeg closes
|
|
636
|
+
if ((err as any).code !== 'EPIPE') {
|
|
637
|
+
logger.error(`FFmpeg stdin error: fileId=${fileId}`, err);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
httpResponse.on('error', (err) => {
|
|
642
|
+
logger.error(`HTTP response error before ffmpeg: fileId=${fileId}`, err);
|
|
643
|
+
try {
|
|
644
|
+
ffmpeg.kill('SIGKILL');
|
|
645
|
+
} catch (e) {
|
|
646
|
+
// Ignore
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
let streamStarted = false;
|
|
651
|
+
|
|
652
|
+
// Stream ffmpeg output
|
|
653
|
+
response.sendStream((async function* () {
|
|
654
|
+
try {
|
|
655
|
+
for await (const chunk of ffmpeg.stdout) {
|
|
656
|
+
if (!streamStarted) {
|
|
657
|
+
streamStarted = true;
|
|
658
|
+
}
|
|
659
|
+
yield chunk;
|
|
660
|
+
}
|
|
661
|
+
} catch (e) {
|
|
662
|
+
logger.error(`Error streaming ffmpeg output: fileId=${fileId}`, e);
|
|
663
|
+
throw e;
|
|
664
|
+
} finally {
|
|
665
|
+
// Clean up ffmpeg process
|
|
666
|
+
try {
|
|
667
|
+
ffmpeg.kill('SIGKILL');
|
|
668
|
+
} catch (e) {
|
|
669
|
+
// Ignore
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
})(), {
|
|
673
|
+
code: 200,
|
|
674
|
+
headers: {
|
|
675
|
+
'Content-Type': 'video/mp4',
|
|
676
|
+
'Accept-Ranges': 'bytes',
|
|
677
|
+
'Cache-Control': 'no-cache',
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// Handle ffmpeg errors
|
|
682
|
+
ffmpeg.on('close', (code) => {
|
|
683
|
+
if (code !== 0 && code !== null && !streamStarted) {
|
|
684
|
+
const sanitized = sanitizeFfmpegOutput(ffmpegError);
|
|
685
|
+
logger.error(`FFmpeg conversion failed: fileId=${fileId}, code=${code}, error=${sanitized}`);
|
|
686
|
+
reject(new Error(`FFmpeg conversion failed: ${code}`));
|
|
687
|
+
} else {
|
|
688
|
+
logger.debug(`FFmpeg conversion completed: fileId=${fileId}, code=${code}`);
|
|
689
|
+
resolve();
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
ffmpeg.on('error', (error) => {
|
|
694
|
+
logger.error(`FFmpeg spawn error: fileId=${fileId}`, error);
|
|
695
|
+
reject(error);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
logger.debug(`FFmpeg conversion started: fileId=${fileId}`);
|
|
699
|
+
} else {
|
|
700
|
+
// Direct proxy for non-FLV content (should be MP4 already)
|
|
701
|
+
// Stream directly without buffering - yield chunks as they arrive
|
|
702
|
+
response.sendStream((async function* () {
|
|
703
|
+
try {
|
|
704
|
+
for await (const chunk of Readable.from(httpResponse)) {
|
|
705
|
+
yield chunk;
|
|
706
|
+
}
|
|
707
|
+
logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
|
|
708
|
+
} catch (streamErr) {
|
|
709
|
+
logger.error(`[VideoClip] Stream error: fileId=${fileId}`, streamErr);
|
|
710
|
+
throw streamErr;
|
|
711
|
+
}
|
|
712
|
+
})(), {
|
|
713
|
+
code: statusCode,
|
|
714
|
+
headers: responseHeaders,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
resolve();
|
|
718
|
+
}
|
|
719
|
+
} catch (err) {
|
|
720
|
+
logger.error(`Error sending stream: fileId=${fileId}`, err);
|
|
721
|
+
reject(err);
|
|
722
|
+
}
|
|
723
|
+
}).on('error', (e) => {
|
|
724
|
+
logger.error(`Error fetching videoclip: fileId=${fileId}`, e);
|
|
725
|
+
reject(e);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
await sendVideo();
|
|
732
|
+
return;
|
|
733
|
+
} catch (e) {
|
|
734
|
+
logger.error(`HTTP proxy error: fileId=${fileId}`, e);
|
|
735
|
+
response.send('Failed to proxy HTTP stream', { code: 500 });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
520
739
|
|
|
521
|
-
//
|
|
740
|
+
// For RTMP URLs (camera standalone), use ffmpeg
|
|
522
741
|
const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
|
|
523
742
|
const ffmpegArgs: string[] = [
|
|
524
743
|
'-i', rtmpVodUrl,
|
|
@@ -548,8 +767,9 @@ export async function handleVideoClipRequest(props: {
|
|
|
548
767
|
}
|
|
549
768
|
yield chunk;
|
|
550
769
|
}
|
|
770
|
+
logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
|
|
551
771
|
} catch (e) {
|
|
552
|
-
logger.error(`
|
|
772
|
+
logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e);
|
|
553
773
|
throw e;
|
|
554
774
|
} finally {
|
|
555
775
|
// Clean up ffmpeg process
|
|
@@ -571,7 +791,8 @@ export async function handleVideoClipRequest(props: {
|
|
|
571
791
|
// Handle ffmpeg errors
|
|
572
792
|
ffmpeg.on('close', (code) => {
|
|
573
793
|
if (code !== 0 && code !== null && !streamStarted) {
|
|
574
|
-
|
|
794
|
+
const sanitized = sanitizeFfmpegOutput(ffmpegError);
|
|
795
|
+
logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${sanitized}`);
|
|
575
796
|
}
|
|
576
797
|
});
|
|
577
798
|
|
|
@@ -581,4 +802,218 @@ export async function handleVideoClipRequest(props: {
|
|
|
581
802
|
|
|
582
803
|
return;
|
|
583
804
|
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Parse recordType string to extract detection classes
|
|
809
|
+
* Based on the same logic as ReolinkCgiApi.parseRecordTypeFlags
|
|
810
|
+
*/
|
|
811
|
+
function parseRecordTypeToDetectionClasses(recordType?: string): string[] {
|
|
812
|
+
const detectionClasses: string[] = [];
|
|
813
|
+
|
|
814
|
+
if (!recordType) {
|
|
815
|
+
return ['Motion']; // Default to Motion if no type specified
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Split by comma or whitespace and process each type
|
|
819
|
+
const types = recordType.toLowerCase().split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
820
|
+
|
|
821
|
+
let hasMotion = false;
|
|
822
|
+
|
|
823
|
+
for (const t of types) {
|
|
824
|
+
if (t === "people" || t === "person") {
|
|
825
|
+
detectionClasses.push('Person');
|
|
826
|
+
} else if (t === "vehicle" || t === "car") {
|
|
827
|
+
detectionClasses.push('Vehicle');
|
|
828
|
+
} else if (t === "dog_cat" || t === "animal" || t === "pet") {
|
|
829
|
+
detectionClasses.push('Animal');
|
|
830
|
+
} else if (t === "face") {
|
|
831
|
+
detectionClasses.push('Face');
|
|
832
|
+
} else if (t === "md" || t === "motion") {
|
|
833
|
+
hasMotion = true;
|
|
834
|
+
} else if (t === "visitor" || t === "doorbell") {
|
|
835
|
+
detectionClasses.push('Doorbell');
|
|
836
|
+
} else if (t === "package") {
|
|
837
|
+
detectionClasses.push('Package');
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Always include Motion as base (if not already added or if no other detections)
|
|
842
|
+
if (hasMotion || detectionClasses.length === 0) {
|
|
843
|
+
detectionClasses.unshift('Motion');
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return detectionClasses;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Extract detection classes from parsed filename flags (hex decoding)
|
|
851
|
+
*/
|
|
852
|
+
function parseFilenameFlagsToDetectionClasses(parsed?: ParsedRecordingFileName): string[] {
|
|
853
|
+
const detectionClasses: string[] = [];
|
|
854
|
+
const flags = parsed?.flags;
|
|
855
|
+
|
|
856
|
+
if (!flags) {
|
|
857
|
+
return [];
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Extract detection types from hex flags (same logic as enrichVodFile)
|
|
861
|
+
if (flags.aiPerson) {
|
|
862
|
+
detectionClasses.push('Person');
|
|
863
|
+
}
|
|
864
|
+
if (flags.aiVehicle) {
|
|
865
|
+
detectionClasses.push('Vehicle');
|
|
866
|
+
}
|
|
867
|
+
if (flags.aiAnimal) {
|
|
868
|
+
detectionClasses.push('Animal');
|
|
869
|
+
}
|
|
870
|
+
if (flags.aiFace) {
|
|
871
|
+
detectionClasses.push('Face');
|
|
872
|
+
}
|
|
873
|
+
if (flags.motion) {
|
|
874
|
+
detectionClasses.push('Motion');
|
|
875
|
+
}
|
|
876
|
+
if (flags.doorbell) {
|
|
877
|
+
detectionClasses.push('Doorbell');
|
|
878
|
+
}
|
|
879
|
+
if (flags.package) {
|
|
880
|
+
detectionClasses.push('Package');
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return detectionClasses;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Convert Reolink time format to Date
|
|
888
|
+
* Uses UTC to match the API's dateToReolinkTime conversion
|
|
889
|
+
*/
|
|
890
|
+
function reolinkTimeToDate(time: { year: number; mon: number; day: number; hour: number; min: number; sec: number }): Date {
|
|
891
|
+
return new Date(Date.UTC(
|
|
892
|
+
time.year,
|
|
893
|
+
time.mon - 1,
|
|
894
|
+
time.day,
|
|
895
|
+
time.hour,
|
|
896
|
+
time.min,
|
|
897
|
+
time.sec
|
|
898
|
+
));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Convert VOD search results to VideoClip array
|
|
903
|
+
*/
|
|
904
|
+
export async function vodSearchResultsToVideoClips(
|
|
905
|
+
vodResults: Array<VodSearchResponse>,
|
|
906
|
+
options: {
|
|
907
|
+
deviceId: string;
|
|
908
|
+
plugin: ScryptedDeviceBase;
|
|
909
|
+
logger?: Console;
|
|
910
|
+
}
|
|
911
|
+
): Promise<VideoClip[]> {
|
|
912
|
+
const { deviceId, plugin, logger } = options;
|
|
913
|
+
const clips: VideoClip[] = [];
|
|
914
|
+
|
|
915
|
+
// Import parseRecordingFileName once (it's exported from the package)
|
|
916
|
+
const reolinkModule = await import("@apocaliss92/reolink-baichuan-js");
|
|
917
|
+
const parseRecordingFileName = reolinkModule.parseRecordingFileName;
|
|
918
|
+
|
|
919
|
+
// Process VOD search results
|
|
920
|
+
for (const result of vodResults) {
|
|
921
|
+
if (result.code !== 0) {
|
|
922
|
+
logger?.debug(`VOD search result code: ${result.code}`, result.error);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const searchResult = result.value?.SearchResult;
|
|
927
|
+
if (!searchResult?.File || !Array.isArray(searchResult.File)) {
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Convert each VOD file to VideoClip
|
|
932
|
+
for (const file of searchResult.File) {
|
|
933
|
+
try {
|
|
934
|
+
// Parse filename to extract flags (like enrichVodFile does)
|
|
935
|
+
const parsed = parseRecordingFileName(file.name);
|
|
936
|
+
|
|
937
|
+
// Get times from parsed filename or from StartTime/EndTime
|
|
938
|
+
// Camera times in StartTime/EndTime are in local timezone (not UTC)
|
|
939
|
+
// Use local time constructor to preserve the timezone
|
|
940
|
+
const fileStart = parsed?.start ?? new Date(
|
|
941
|
+
file.StartTime.year,
|
|
942
|
+
file.StartTime.mon - 1,
|
|
943
|
+
file.StartTime.day,
|
|
944
|
+
file.StartTime.hour,
|
|
945
|
+
file.StartTime.min,
|
|
946
|
+
file.StartTime.sec
|
|
947
|
+
);
|
|
948
|
+
const fileEnd = parsed?.end ?? new Date(
|
|
949
|
+
file.EndTime.year,
|
|
950
|
+
file.EndTime.mon - 1,
|
|
951
|
+
file.EndTime.day,
|
|
952
|
+
file.EndTime.hour,
|
|
953
|
+
file.EndTime.min,
|
|
954
|
+
file.EndTime.sec
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
const duration = fileEnd.getTime() - fileStart.getTime();
|
|
958
|
+
const fileName = file.name || '';
|
|
959
|
+
|
|
960
|
+
// Extract detection classes from both filename flags (hex) and file.type
|
|
961
|
+
const filenameFlags = parseFilenameFlagsToDetectionClasses(parsed);
|
|
962
|
+
const typeFlags = parseRecordTypeToDetectionClasses(file.type);
|
|
963
|
+
|
|
964
|
+
// Debug: log file.type to see what we're parsing
|
|
965
|
+
if (logger && file.type) {
|
|
966
|
+
logger.debug(`[VOD] Parsing file.type="${file.type}" for file=${fileName}, filenameFlags=${filenameFlags.join(',')}, typeFlags=${typeFlags.join(',')}`);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Merge both sources (OR them together, like enrichVodFile does)
|
|
970
|
+
// Remove duplicates and ensure Motion is included if any detection is found
|
|
971
|
+
const allDetections = [...filenameFlags, ...typeFlags];
|
|
972
|
+
const detectionClasses = [...new Set(allDetections)];
|
|
973
|
+
|
|
974
|
+
// If we have detections from filename flags, use those (they're more accurate)
|
|
975
|
+
// Otherwise use type flags, or default to Motion
|
|
976
|
+
if (detectionClasses.length === 0) {
|
|
977
|
+
detectionClasses.push('Motion');
|
|
978
|
+
} else if (!detectionClasses.includes('Motion') && (filenameFlags.length > 0 || typeFlags.some(t => t.toLowerCase().includes('motion') || t.toLowerCase().includes('md')))) {
|
|
979
|
+
// Add Motion if we have other detections but Motion is not explicitly included
|
|
980
|
+
detectionClasses.push('Motion');
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Generate webhook URLs
|
|
984
|
+
let videoHref: string | undefined;
|
|
985
|
+
let thumbnailHref: string | undefined;
|
|
986
|
+
try {
|
|
987
|
+
const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
|
|
988
|
+
deviceId,
|
|
989
|
+
fileId: fileName,
|
|
990
|
+
plugin,
|
|
991
|
+
logger,
|
|
992
|
+
});
|
|
993
|
+
videoHref = videoUrl;
|
|
994
|
+
thumbnailHref = thumbnailUrl;
|
|
995
|
+
} catch (e) {
|
|
996
|
+
logger?.debug('Failed to generate webhook URLs for VOD file', fileName, e);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const clip: VideoClip = {
|
|
1000
|
+
id: fileName,
|
|
1001
|
+
description: fileName,
|
|
1002
|
+
startTime: fileStart.getTime(),
|
|
1003
|
+
duration,
|
|
1004
|
+
resources: {
|
|
1005
|
+
video: videoHref ? { href: videoHref } : undefined,
|
|
1006
|
+
thumbnail: thumbnailHref ? { href: thumbnailHref } : undefined,
|
|
1007
|
+
},
|
|
1008
|
+
detectionClasses,
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
clips.push(clip);
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
logger?.warn(`Failed to convert VOD file to clip: ${file.name}`, e);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return clips;
|
|
584
1019
|
}
|