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:
Alireza Vaezi
2025-08-04 16:21:22 -04:00
parent 551e5dc2e3
commit 1aaac68edd
36 changed files with 1446 additions and 4578 deletions

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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);
}
/**

View File

@@ -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
View 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,
};
}