@@ -275,6 +275,8 @@ def validate_operations(operations: List[Dict[str, Any]]) -> List[Dict[str, Any]
275275 validated_op = validate_subtitle_operation (op )
276276 elif op_type == "concat" :
277277 validated_op = validate_concat_operation (op )
278+ elif op_type == "thumbnail" :
279+ validated_op = validate_thumbnail_operation (op )
278280 else :
279281 raise ValueError (f"Unknown operation type: { op_type } " )
280282
@@ -608,6 +610,92 @@ def validate_subtitle_operation(op: Dict[str, Any]) -> Dict[str, Any]:
608610 return validated
609611
610612
613+ def validate_thumbnail_operation (op : Dict [str , Any ]) -> Dict [str , Any ]:
614+ """Validate thumbnail extraction operation."""
615+ validated = {"type" : "thumbnail" }
616+
617+ # Validate mode
618+ mode = op .get ("mode" , "single" )
619+ allowed_modes = {"single" , "multiple" , "best" , "sprite" }
620+ if mode not in allowed_modes :
621+ raise ValueError (f"Invalid thumbnail mode: { mode } . Allowed: { ', ' .join (allowed_modes )} " )
622+ validated ["mode" ] = mode
623+
624+ # Validate time (for single mode)
625+ if "time" in op :
626+ time = op ["time" ]
627+ if isinstance (time , (int , float )):
628+ if time < 0 or time > 86400 :
629+ raise ValueError ("Time out of valid range (0-86400 seconds)" )
630+ validated ["time" ] = float (time )
631+ elif isinstance (time , str ):
632+ validated ["time" ] = parse_time_string (time )
633+ else :
634+ raise ValueError ("Time must be a number or time string" )
635+
636+ # Validate count
637+ if "count" in op :
638+ count = op ["count" ]
639+ if not isinstance (count , int ) or count < 1 or count > 1000 :
640+ raise ValueError ("Count must be an integer between 1 and 1000" )
641+ validated ["count" ] = count
642+
643+ # Validate interval
644+ if "interval" in op :
645+ interval = op ["interval" ]
646+ if not isinstance (interval , (int , float )) or interval <= 0 :
647+ raise ValueError ("Interval must be a positive number" )
648+ validated ["interval" ] = float (interval )
649+
650+ # Validate dimensions
651+ if "width" in op :
652+ width = op ["width" ]
653+ if not isinstance (width , int ) or width < 16 or width > 7680 :
654+ raise ValueError ("Width must be an integer between 16 and 7680" )
655+ validated ["width" ] = width
656+
657+ if "height" in op :
658+ height = op ["height" ]
659+ if not isinstance (height , int ) or height < 16 or height > 4320 :
660+ raise ValueError ("Height must be an integer between 16 and 4320" )
661+ validated ["height" ] = height
662+
663+ # Validate quality (JPEG quality, 2-31 where lower is better)
664+ if "quality" in op :
665+ quality = op ["quality" ]
666+ if not isinstance (quality , int ) or quality < 2 or quality > 31 :
667+ raise ValueError ("Quality must be an integer between 2 and 31 (lower is better)" )
668+ validated ["quality" ] = quality
669+
670+ # Sprite-specific options
671+ if mode == "sprite" :
672+ if "cols" in op :
673+ cols = op ["cols" ]
674+ if not isinstance (cols , int ) or cols < 1 or cols > 20 :
675+ raise ValueError ("Cols must be an integer between 1 and 20" )
676+ validated ["cols" ] = cols
677+
678+ if "rows" in op :
679+ rows = op ["rows" ]
680+ if not isinstance (rows , int ) or rows < 1 or rows > 20 :
681+ raise ValueError ("Rows must be an integer between 1 and 20" )
682+ validated ["rows" ] = rows
683+
684+ if "tile_width" in op :
685+ validated ["tile_width" ] = int (op ["tile_width" ])
686+ if "tile_height" in op :
687+ validated ["tile_height" ] = int (op ["tile_height" ])
688+
689+ # Best mode options
690+ if mode == "best" and "sample_frames" in op :
691+ sample = op ["sample_frames" ]
692+ if not isinstance (sample , int ) or sample < 10 or sample > 1000 :
693+ raise ValueError ("Sample frames must be an integer between 10 and 1000" )
694+ validated ["sample_frames" ] = sample
695+
696+ return validated
697+
698+
611699def validate_concat_operation (op : Dict [str , Any ]) -> Dict [str , Any ]:
612700 """Validate concatenation operation."""
613701 validated = {"type" : "concat" }
@@ -637,12 +725,22 @@ def validate_transcode_operation(op: Dict[str, Any]) -> Dict[str, Any]:
637725 validated = {"type" : "transcode" }
638726
639727 # Allowed video codecs
640- ALLOWED_VIDEO_CODECS = {'h264' , 'h265' , 'hevc' , 'vp8' , 'vp9' , 'av1' , 'libx264' , 'libx265' , 'copy' , 'prores' , 'dnxhd' }
641- ALLOWED_AUDIO_CODECS = {'aac' , 'mp3' , 'opus' , 'vorbis' , 'ac3' , 'eac3' , 'libfdk_aac' , 'flac' , 'pcm_s16le' , 'pcm_s24le' , 'copy' }
642- ALLOWED_PRESETS = {'ultrafast' , 'superfast' , 'veryfast' , 'faster' , 'fast' , 'medium' , 'slow' , 'slower' , 'veryslow' }
728+ ALLOWED_VIDEO_CODECS = {
729+ 'h264' , 'h265' , 'hevc' , 'vp8' , 'vp9' , 'av1' ,
730+ 'libx264' , 'libx265' , 'libvpx' , 'libvpx-vp9' , 'libaom-av1' , 'libsvtav1' ,
731+ 'prores' , 'prores_ks' , 'dnxhd' , 'dnxhr' , 'copy'
732+ }
733+ ALLOWED_AUDIO_CODECS = {
734+ 'aac' , 'mp3' , 'opus' , 'vorbis' , 'ac3' , 'eac3' ,
735+ 'libfdk_aac' , 'libopus' , 'libvorbis' , 'libmp3lame' ,
736+ 'flac' , 'pcm_s16le' , 'pcm_s24le' , 'pcm_s32le' , 'pcm_f32le' , 'copy'
737+ }
738+ ALLOWED_PRESETS = {'ultrafast' , 'superfast' , 'veryfast' , 'faster' , 'fast' , 'medium' , 'slow' , 'slower' , 'veryslow' , 'placebo' }
643739 ALLOWED_PROFILES = {'baseline' , 'main' , 'high' , 'high10' , 'high422' , 'high444' }
644- ALLOWED_PIXEL_FORMATS = {'yuv420p' , 'yuv422p' , 'yuv444p' , 'yuv420p10le' , 'yuv422p10le' , 'rgb24' , 'rgba' }
645- ALLOWED_HW_ACCEL = {'auto' , 'none' , 'nvenc' , 'qsv' , 'vaapi' , 'videotoolbox' }
740+ ALLOWED_PIXEL_FORMATS = {'yuv420p' , 'yuv422p' , 'yuv444p' , 'yuv420p10le' , 'yuv422p10le' , 'yuv444p10le' , 'rgb24' , 'rgba' , 'nv12' , 'p010le' }
741+ ALLOWED_HW_ACCEL = {'auto' , 'none' , 'nvenc' , 'qsv' , 'vaapi' , 'videotoolbox' , 'amf' }
742+ ALLOWED_TUNES = {'film' , 'animation' , 'grain' , 'stillimage' , 'fastdecode' , 'zerolatency' , 'psnr' , 'ssim' }
743+ ALLOWED_LEVELS = {'1' , '1.1' , '1.2' , '1.3' , '2' , '2.1' , '2.2' , '3' , '3.1' , '3.2' , '4' , '4.1' , '4.2' , '5' , '5.1' , '5.2' , '6' , '6.1' , '6.2' }
646744
647745 # Validate video codec
648746 if "video_codec" in op :
@@ -756,6 +854,59 @@ def validate_transcode_operation(op: Dict[str, Any]) -> Dict[str, Any]:
756854 if "two_pass" in op :
757855 validated ["two_pass" ] = bool (op ["two_pass" ])
758856
857+ # Validate tune parameter (for x264/x265)
858+ if "tune" in op :
859+ tune = op ["tune" ]
860+ if not isinstance (tune , str ):
861+ raise ValueError ("Tune must be a string" )
862+ if tune not in ALLOWED_TUNES :
863+ raise ValueError (f"Invalid tune: { tune } . Allowed: { ', ' .join (ALLOWED_TUNES )} " )
864+ validated ["tune" ] = tune
865+
866+ # Validate level parameter (for H.264/H.265)
867+ if "level" in op :
868+ level = str (op ["level" ])
869+ if level not in ALLOWED_LEVELS :
870+ raise ValueError (f"Invalid level: { level } . Allowed: { ', ' .join (sorted (ALLOWED_LEVELS , key = lambda x : float (x )))} " )
871+ validated ["level" ] = level
872+
873+ # Validate encoder selection (e.g., 'svt' for SVT-AV1)
874+ if "encoder" in op :
875+ allowed_encoders = {'default' , 'svt' , 'aom' , 'rav1e' }
876+ if op ["encoder" ] not in allowed_encoders :
877+ raise ValueError (f"Invalid encoder: { op ['encoder' ]} " )
878+ validated ["encoder" ] = op ["encoder" ]
879+
880+ # Validate reference frames
881+ if "ref_frames" in op or "refs" in op :
882+ refs = op .get ("ref_frames" ) or op .get ("refs" )
883+ if isinstance (refs , int ):
884+ if refs < 1 or refs > 16 :
885+ raise ValueError ("Reference frames out of valid range (1-16)" )
886+ validated ["ref_frames" ] = refs
887+ else :
888+ raise ValueError ("Reference frames must be an integer" )
889+
890+ # Validate lookahead
891+ if "rc_lookahead" in op :
892+ lookahead = op ["rc_lookahead" ]
893+ if isinstance (lookahead , int ):
894+ if lookahead < 0 or lookahead > 250 :
895+ raise ValueError ("RC lookahead out of valid range (0-250)" )
896+ validated ["rc_lookahead" ] = lookahead
897+ else :
898+ raise ValueError ("RC lookahead must be an integer" )
899+
900+ # Validate scene change threshold
901+ if "sc_threshold" in op :
902+ sc = op ["sc_threshold" ]
903+ if isinstance (sc , int ):
904+ if sc < 0 or sc > 100 :
905+ raise ValueError ("Scene change threshold out of valid range (0-100)" )
906+ validated ["sc_threshold" ] = sc
907+ else :
908+ raise ValueError ("Scene change threshold must be an integer" )
909+
759910 # Validate audio sample rate
760911 if "audio_sample_rate" in op :
761912 sr = op ["audio_sample_rate" ]
@@ -885,10 +1036,22 @@ def validate_resource_limits(operations: List[Dict[str, Any]]) -> None:
8851036 if fps and fps > 120 :
8861037 raise ValueError (f"Frame rate too high: { fps } fps (max 120)" )
8871038
888- # Check quality settings
1039+ # Check quality settings - allow CRF 0 for lossless encoding
8891040 crf = op .get ("crf" )
890- if crf is not None and crf < 10 :
891- raise ValueError (f"CRF too low (too high quality): { crf } (min 10 for resource management)" )
1041+ if crf is not None :
1042+ # CRF 0-4 is typically lossless/near-lossless, requires explicit opt-in
1043+ if crf < 0 :
1044+ raise ValueError (f"CRF cannot be negative: { crf } " )
1045+ elif crf < 5 :
1046+ # Allow but log warning for very high quality settings
1047+ if not op .get ("allow_lossless" , False ):
1048+ import structlog
1049+ logger = structlog .get_logger ()
1050+ logger .warning (
1051+ "Very low CRF requested (high quality/lossless)" ,
1052+ crf = crf ,
1053+ tip = "Set allow_lossless=true to suppress this warning"
1054+ )
8921055
8931056 elif op .get ("type" ) == "stream" :
8941057 # Check streaming variants
0 commit comments