feat(video): Implement MP4 format support across frontend and backend
- Updated VideoModal to display web compatibility status for video formats. - Enhanced VideoPlayer to dynamically fetch video MIME types and handle MP4 streaming. - Introduced video file utilities for better handling of video formats and MIME types. - Modified CameraConfig interface to include new video recording settings (format, codec, quality). - Created comprehensive documentation for MP4 format integration and frontend implementation. - Ensured backward compatibility with existing AVI files while promoting MP4 as the preferred format. - Added validation and error handling for video format configurations.
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { visionApi, type CameraConfig, type CameraConfigUpdate } from '../lib/visionApi'
|
||||
import {
|
||||
getAvailableCodecs,
|
||||
validateVideoFormatConfig,
|
||||
requiresRestart,
|
||||
getRecommendedVideoSettings
|
||||
} from '../utils/videoFileUtils'
|
||||
|
||||
interface CameraConfigModalProps {
|
||||
cameraName: string
|
||||
@@ -17,6 +23,8 @@ export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onEr
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [originalConfig, setOriginalConfig] = useState<CameraConfig | null>(null)
|
||||
const [videoFormatWarnings, setVideoFormatWarnings] = useState<string[]>([])
|
||||
const [needsRestart, setNeedsRestart] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && cameraName) {
|
||||
@@ -29,11 +37,67 @@ export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onEr
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const configData = await visionApi.getCameraConfig(cameraName)
|
||||
setConfig(configData)
|
||||
setOriginalConfig(configData)
|
||||
|
||||
// Ensure video format fields have default values for backward compatibility
|
||||
const configWithDefaults = {
|
||||
...configData,
|
||||
video_format: configData.video_format || 'mp4',
|
||||
video_codec: configData.video_codec || 'mp4v',
|
||||
video_quality: configData.video_quality ?? 95,
|
||||
}
|
||||
|
||||
setConfig(configWithDefaults)
|
||||
setOriginalConfig(configWithDefaults)
|
||||
setHasChanges(false)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load camera configuration'
|
||||
let errorMessage = 'Failed to load camera configuration'
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message
|
||||
|
||||
// Handle specific API validation errors for missing video format fields
|
||||
if (err.message.includes('video_format') || err.message.includes('video_codec') || err.message.includes('video_quality')) {
|
||||
errorMessage = 'Camera configuration is missing video format settings. This may indicate the backend needs to be updated to support MP4 format. Using default values.'
|
||||
|
||||
// Create a default configuration for display
|
||||
const defaultConfig = {
|
||||
name: cameraName,
|
||||
machine_topic: '',
|
||||
storage_path: '',
|
||||
enabled: true,
|
||||
auto_record_on_machine_start: false,
|
||||
auto_start_recording_enabled: false,
|
||||
auto_recording_max_retries: 3,
|
||||
auto_recording_retry_delay_seconds: 2,
|
||||
exposure_ms: 1.0,
|
||||
gain: 3.5,
|
||||
target_fps: 0,
|
||||
video_format: 'mp4',
|
||||
video_codec: 'mp4v',
|
||||
video_quality: 95,
|
||||
sharpness: 120,
|
||||
contrast: 110,
|
||||
saturation: 100,
|
||||
gamma: 100,
|
||||
noise_filter_enabled: true,
|
||||
denoise_3d_enabled: false,
|
||||
auto_white_balance: true,
|
||||
color_temperature_preset: 0,
|
||||
anti_flicker_enabled: true,
|
||||
light_frequency: 1,
|
||||
bit_depth: 8,
|
||||
hdr_enabled: false,
|
||||
hdr_gain_mode: 0,
|
||||
}
|
||||
|
||||
setConfig(defaultConfig)
|
||||
setOriginalConfig(defaultConfig)
|
||||
setHasChanges(false)
|
||||
setError(errorMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
@@ -41,7 +105,7 @@ export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onEr
|
||||
}
|
||||
}
|
||||
|
||||
const updateSetting = (key: keyof CameraConfigUpdate, value: number | boolean) => {
|
||||
const updateSetting = (key: keyof CameraConfigUpdate, value: number | boolean | string) => {
|
||||
if (!config) return
|
||||
|
||||
const newConfig = { ...config, [key]: value }
|
||||
@@ -53,6 +117,21 @@ export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onEr
|
||||
return newConfig[configKey] !== originalConfig[configKey]
|
||||
})
|
||||
setHasChanges(!!hasChanges)
|
||||
|
||||
// Check if video format changes require restart
|
||||
if (originalConfig && (key === 'video_format' || key === 'video_codec' || key === 'video_quality')) {
|
||||
const currentFormat = originalConfig.video_format || 'mp4'
|
||||
const newFormat = key === 'video_format' ? value as string : newConfig.video_format || 'mp4'
|
||||
setNeedsRestart(requiresRestart(currentFormat, newFormat))
|
||||
|
||||
// Validate video format configuration
|
||||
const validation = validateVideoFormatConfig({
|
||||
video_format: newConfig.video_format || 'mp4',
|
||||
video_codec: newConfig.video_codec || 'mp4v',
|
||||
video_quality: newConfig.video_quality ?? 95,
|
||||
})
|
||||
setVideoFormatWarnings(validation.warnings)
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
@@ -162,7 +241,24 @@ export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onEr
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Configuration Error</h3>
|
||||
<p className="mt-2 text-sm text-red-700">{error}</p>
|
||||
{error.includes('video_format') && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
<strong>Note:</strong> The video format settings are displayed with default values.
|
||||
You can still modify and save the configuration, but the backend may need to be updated
|
||||
to fully support MP4 format settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -440,6 +536,105 @@ export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onEr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Recording Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Video Recording Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Video Format
|
||||
</label>
|
||||
<select
|
||||
value={config.video_format || 'mp4'}
|
||||
onChange={(e) => updateSetting('video_format', e.target.value)}
|
||||
className="w-full border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="mp4">MP4 (Recommended)</option>
|
||||
<option value="avi">AVI (Legacy)</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">MP4 provides better web compatibility and smaller file sizes</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Video Codec
|
||||
</label>
|
||||
<select
|
||||
value={config.video_codec || 'mp4v'}
|
||||
onChange={(e) => updateSetting('video_codec', e.target.value)}
|
||||
className="w-full border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
{getAvailableCodecs(config.video_format || 'mp4').map(codec => (
|
||||
<option key={codec} value={codec}>{codec.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">Video compression codec</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Video Quality: {config.video_quality ?? 95}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="100"
|
||||
step="5"
|
||||
value={config.video_quality ?? 95}
|
||||
onChange={(e) => updateSetting('video_quality', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>50% (Smaller files)</span>
|
||||
<span>100% (Best quality)</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Higher quality = larger file sizes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Format Warnings */}
|
||||
{videoFormatWarnings.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">Video Format Warnings</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{videoFormatWarnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart Warning */}
|
||||
{needsRestart && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Restart Required</h3>
|
||||
<p className="mt-2 text-sm text-red-700">
|
||||
Video format changes require a camera service restart to take effect. Use "Apply & Restart" to apply these changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-Recording Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Auto-Recording Settings</h4>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getStatusBadgeClass,
|
||||
getResolutionString,
|
||||
formatDuration,
|
||||
isWebCompatible,
|
||||
} from '../utils/videoUtils';
|
||||
|
||||
interface VideoModalProps {
|
||||
@@ -103,13 +104,21 @@ export const VideoModal: React.FC<VideoModalProps> = ({
|
||||
<div className="w-full lg:w-80 bg-gray-50 overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Status and Format */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 flex-wrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClass(video.status)}`}>
|
||||
{video.status}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isWebCompatible(video.format)
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-orange-100 text-orange-800'
|
||||
}`}>
|
||||
{getFormatDisplayName(video.format)}
|
||||
</span>
|
||||
{isWebCompatible(video.format) && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Web Compatible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
* Uses the useVideoPlayer hook for state management and provides a clean interface.
|
||||
*/
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import React, { forwardRef, useState, useEffect } from 'react';
|
||||
import { useVideoPlayer } from '../hooks/useVideoPlayer';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import { type VideoPlayerProps } from '../types';
|
||||
import { formatDuration } from '../utils/videoUtils';
|
||||
import { formatDuration, getVideoMimeType } from '../utils/videoUtils';
|
||||
|
||||
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
|
||||
fileId,
|
||||
@@ -23,6 +23,10 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
|
||||
onEnded,
|
||||
onError,
|
||||
}, forwardedRef) => {
|
||||
const [videoInfo, setVideoInfo] = useState<{ filename?: string; mimeType: string }>({
|
||||
mimeType: 'video/mp4' // Default to MP4
|
||||
});
|
||||
|
||||
const { state, actions, ref } = useVideoPlayer({
|
||||
autoPlay,
|
||||
onPlay,
|
||||
@@ -36,6 +40,26 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
|
||||
|
||||
const streamingUrl = videoApiService.getStreamingUrl(fileId);
|
||||
|
||||
// Fetch video info to determine MIME type
|
||||
useEffect(() => {
|
||||
const fetchVideoInfo = async () => {
|
||||
try {
|
||||
const info = await videoApiService.getVideoInfo(fileId);
|
||||
if (info.file_id) {
|
||||
// Extract filename from file_id or use a default pattern
|
||||
const filename = info.file_id.includes('.') ? info.file_id : `${info.file_id}.mp4`;
|
||||
const mimeType = getVideoMimeType(filename);
|
||||
setVideoInfo({ filename, mimeType });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch video info, using default MIME type:', error);
|
||||
// Keep default MP4 MIME type
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideoInfo();
|
||||
}, [fileId]);
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
|
||||
@@ -59,8 +83,13 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
|
||||
className="w-full h-full bg-black"
|
||||
controls={!controls} // Use native controls if custom controls are disabled
|
||||
style={{ width, height }}
|
||||
playsInline // Important for iOS compatibility
|
||||
>
|
||||
<source src={streamingUrl} type="video/mp4" />
|
||||
<source src={streamingUrl} type={videoInfo.mimeType} />
|
||||
{/* Fallback for MP4 if original format fails */}
|
||||
{videoInfo.mimeType !== 'video/mp4' && (
|
||||
<source src={streamingUrl} type="video/mp4" />
|
||||
)}
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/**
|
||||
* Video Streaming Utilities
|
||||
*
|
||||
*
|
||||
* Pure utility functions for video operations, formatting, and data processing.
|
||||
* These functions have no side effects and can be easily tested.
|
||||
* Enhanced with MP4 format support and improved file handling.
|
||||
*/
|
||||
|
||||
import { type VideoFile, type VideoWithMetadata } from '../types';
|
||||
import {
|
||||
isVideoFile as isVideoFileUtil,
|
||||
getVideoMimeType as getVideoMimeTypeUtil,
|
||||
getVideoFormat,
|
||||
isWebCompatibleFormat,
|
||||
getFormatDisplayName as getFormatDisplayNameUtil
|
||||
} from '../../../utils/videoFileUtils';
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human readable format
|
||||
@@ -72,6 +80,20 @@ export function getRelativeTime(dateString: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename is a video file (supports MP4, AVI, and other formats)
|
||||
*/
|
||||
export function isVideoFile(filename: string): boolean {
|
||||
return isVideoFileUtil(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for video file based on filename
|
||||
*/
|
||||
export function getVideoMimeType(filename: string): string {
|
||||
return getVideoMimeTypeUtil(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract camera name from filename if not provided
|
||||
*/
|
||||
@@ -85,23 +107,14 @@ export function extractCameraName(filename: string): string {
|
||||
* Get video format display name
|
||||
*/
|
||||
export function getFormatDisplayName(format: string): string {
|
||||
const formatMap: Record<string, string> = {
|
||||
'avi': 'AVI',
|
||||
'mp4': 'MP4',
|
||||
'webm': 'WebM',
|
||||
'mov': 'MOV',
|
||||
'mkv': 'MKV',
|
||||
};
|
||||
|
||||
return formatMap[format.toLowerCase()] || format.toUpperCase();
|
||||
return getFormatDisplayNameUtil(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if video format is web-compatible
|
||||
*/
|
||||
export function isWebCompatible(format: string): boolean {
|
||||
const webFormats = ['mp4', 'webm', 'ogg'];
|
||||
return webFormats.includes(format.toLowerCase());
|
||||
return isWebCompatibleFormat(format);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -156,6 +156,10 @@ export interface CameraConfig {
|
||||
exposure_ms: number
|
||||
gain: number
|
||||
target_fps: number
|
||||
// NEW VIDEO RECORDING SETTINGS (MP4 format support)
|
||||
video_format?: string // 'mp4' or 'avi' (optional for backward compatibility)
|
||||
video_codec?: string // 'mp4v', 'XVID', 'MJPG' (optional for backward compatibility)
|
||||
video_quality?: number // 0-100 (higher = better quality) (optional for backward compatibility)
|
||||
sharpness: number
|
||||
contrast: number
|
||||
saturation: number
|
||||
@@ -179,6 +183,10 @@ export interface CameraConfigUpdate {
|
||||
exposure_ms?: number
|
||||
gain?: number
|
||||
target_fps?: number
|
||||
// NEW VIDEO RECORDING SETTINGS (MP4 format support)
|
||||
video_format?: string // 'mp4' or 'avi'
|
||||
video_codec?: string // 'mp4v', 'XVID', 'MJPG'
|
||||
video_quality?: number // 0-100 (higher = better quality)
|
||||
sharpness?: number
|
||||
contrast?: number
|
||||
saturation?: number
|
||||
|
||||
302
src/utils/videoFileUtils.ts
Normal file
302
src/utils/videoFileUtils.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Video File Utilities
|
||||
*
|
||||
* Utility functions for handling video files, extensions, MIME types, and format validation.
|
||||
* Supports both MP4 and AVI formats with backward compatibility.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported video file extensions
|
||||
*/
|
||||
export const VIDEO_EXTENSIONS = ['.mp4', '.avi', '.webm', '.mov', '.mkv'] as const;
|
||||
|
||||
/**
|
||||
* Video format to MIME type mapping
|
||||
*/
|
||||
export const VIDEO_MIME_TYPES: Record<string, string> = {
|
||||
'mp4': 'video/mp4',
|
||||
'avi': 'video/x-msvideo',
|
||||
'webm': 'video/webm',
|
||||
'mov': 'video/quicktime',
|
||||
'mkv': 'video/x-matroska',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Video codec options for each format
|
||||
*/
|
||||
export const VIDEO_CODECS: Record<string, string[]> = {
|
||||
'mp4': ['mp4v', 'h264', 'h265'],
|
||||
'avi': ['XVID', 'MJPG', 'h264'],
|
||||
'webm': ['vp8', 'vp9'],
|
||||
'mov': ['h264', 'h265', 'prores'],
|
||||
'mkv': ['h264', 'h265', 'vp9'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check if a filename has a video file extension
|
||||
*/
|
||||
export function isVideoFile(filename: string): boolean {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
return VIDEO_EXTENSIONS.some(ext => lowerFilename.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file extension from filename (without the dot)
|
||||
*/
|
||||
export function getFileExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1 || lastDotIndex === filename.length - 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return filename.substring(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video format from filename
|
||||
*/
|
||||
export function getVideoFormat(filename: string): string {
|
||||
const extension = getFileExtension(filename);
|
||||
return extension || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for a video file based on filename
|
||||
*/
|
||||
export function getVideoMimeType(filename: string): string {
|
||||
const format = getVideoFormat(filename);
|
||||
return VIDEO_MIME_TYPES[format] || 'video/mp4'; // Default to MP4 for new files
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a video format is web-compatible (can be played in browsers)
|
||||
*/
|
||||
export function isWebCompatibleFormat(format: string): boolean {
|
||||
const webCompatibleFormats = ['mp4', 'webm', 'ogg'];
|
||||
return webCompatibleFormats.includes(format.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for video format
|
||||
*/
|
||||
export function getFormatDisplayName(format: string): string {
|
||||
const formatNames: Record<string, string> = {
|
||||
'mp4': 'MP4',
|
||||
'avi': 'AVI',
|
||||
'webm': 'WebM',
|
||||
'mov': 'QuickTime',
|
||||
'mkv': 'Matroska',
|
||||
};
|
||||
|
||||
return formatNames[format.toLowerCase()] || format.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video format setting
|
||||
*/
|
||||
export function isValidVideoFormat(format: string): boolean {
|
||||
const validFormats = ['mp4', 'avi', 'webm', 'mov', 'mkv'];
|
||||
return validFormats.includes(format.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video codec for a given format
|
||||
*/
|
||||
export function isValidCodecForFormat(codec: string, format: string): boolean {
|
||||
const validCodecs = VIDEO_CODECS[format.toLowerCase()];
|
||||
return validCodecs ? validCodecs.includes(codec) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available codecs for a video format
|
||||
*/
|
||||
export function getAvailableCodecs(format: string): string[] {
|
||||
return VIDEO_CODECS[format.toLowerCase()] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video quality setting (0-100)
|
||||
*/
|
||||
export function isValidVideoQuality(quality: number): boolean {
|
||||
return typeof quality === 'number' && quality >= 0 && quality <= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended video settings for different use cases
|
||||
*/
|
||||
export function getRecommendedVideoSettings(useCase: 'production' | 'storage-optimized' | 'legacy') {
|
||||
const settings = {
|
||||
production: {
|
||||
video_format: 'mp4',
|
||||
video_codec: 'mp4v',
|
||||
video_quality: 95,
|
||||
},
|
||||
'storage-optimized': {
|
||||
video_format: 'mp4',
|
||||
video_codec: 'mp4v',
|
||||
video_quality: 85,
|
||||
},
|
||||
legacy: {
|
||||
video_format: 'avi',
|
||||
video_codec: 'XVID',
|
||||
video_quality: 95,
|
||||
},
|
||||
};
|
||||
|
||||
return settings[useCase];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if video format change requires camera restart
|
||||
*/
|
||||
export function requiresRestart(currentFormat: string, newFormat: string): boolean {
|
||||
// Format changes always require restart
|
||||
return currentFormat !== newFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get format-specific file size estimation factor
|
||||
* (relative to AVI baseline)
|
||||
*/
|
||||
export function getFileSizeFactor(format: string): number {
|
||||
const factors: Record<string, number> = {
|
||||
'mp4': 0.6, // ~40% smaller than AVI
|
||||
'avi': 1.0, // baseline
|
||||
'webm': 0.5, // even smaller
|
||||
'mov': 0.8, // slightly smaller
|
||||
'mkv': 0.7, // moderately smaller
|
||||
};
|
||||
|
||||
return factors[format.toLowerCase()] || 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate file size for a video recording
|
||||
*/
|
||||
export function estimateFileSize(
|
||||
durationSeconds: number,
|
||||
format: string,
|
||||
quality: number,
|
||||
baselineMBPerMinute: number = 30
|
||||
): number {
|
||||
const durationMinutes = durationSeconds / 60;
|
||||
const qualityFactor = quality / 100;
|
||||
const formatFactor = getFileSizeFactor(format);
|
||||
|
||||
return durationMinutes * baselineMBPerMinute * qualityFactor * formatFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate video filename with proper extension
|
||||
*/
|
||||
export function generateVideoFilename(
|
||||
cameraName: string,
|
||||
format: string,
|
||||
timestamp?: Date
|
||||
): string {
|
||||
const date = timestamp || new Date();
|
||||
const dateStr = date.toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '_');
|
||||
const extension = format.toLowerCase();
|
||||
|
||||
return `${cameraName}_recording_${dateStr}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse video filename to extract metadata
|
||||
*/
|
||||
export function parseVideoFilename(filename: string): {
|
||||
cameraName?: string;
|
||||
timestamp?: Date;
|
||||
format: string;
|
||||
isValid: boolean;
|
||||
} {
|
||||
const format = getVideoFormat(filename);
|
||||
|
||||
// Try to match pattern: cameraName_recording_YYYYMMDD_HHMMSS.ext
|
||||
const match = filename.match(/^([^_]+)_recording_(\d{8})_(\d{6})\./);
|
||||
|
||||
if (match) {
|
||||
const [, cameraName, dateStr, timeStr] = match;
|
||||
const year = parseInt(dateStr.slice(0, 4));
|
||||
const month = parseInt(dateStr.slice(4, 6)) - 1; // Month is 0-indexed
|
||||
const day = parseInt(dateStr.slice(6, 8));
|
||||
const hour = parseInt(timeStr.slice(0, 2));
|
||||
const minute = parseInt(timeStr.slice(2, 4));
|
||||
const second = parseInt(timeStr.slice(4, 6));
|
||||
|
||||
const timestamp = new Date(year, month, day, hour, minute, second);
|
||||
|
||||
return {
|
||||
cameraName,
|
||||
timestamp,
|
||||
format,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
format,
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Video format configuration validation
|
||||
*/
|
||||
export interface VideoFormatValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate complete video format configuration
|
||||
*/
|
||||
export function validateVideoFormatConfig(config: {
|
||||
video_format?: string;
|
||||
video_codec?: string;
|
||||
video_quality?: number;
|
||||
}): VideoFormatValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate format
|
||||
if (config.video_format && !isValidVideoFormat(config.video_format)) {
|
||||
errors.push(`Invalid video format: ${config.video_format}`);
|
||||
}
|
||||
|
||||
// Validate codec
|
||||
if (config.video_format && config.video_codec) {
|
||||
if (!isValidCodecForFormat(config.video_codec, config.video_format)) {
|
||||
errors.push(`Codec ${config.video_codec} is not valid for format ${config.video_format}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate quality
|
||||
if (config.video_quality !== undefined && !isValidVideoQuality(config.video_quality)) {
|
||||
errors.push(`Video quality must be between 0 and 100, got: ${config.video_quality}`);
|
||||
}
|
||||
|
||||
// Add warnings
|
||||
if (config.video_format === 'avi') {
|
||||
warnings.push('AVI format has limited web compatibility. Consider using MP4 for better browser support.');
|
||||
}
|
||||
|
||||
if (config.video_quality && config.video_quality < 70) {
|
||||
warnings.push('Low video quality may affect analysis accuracy.');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user