Skip to content

Commit 571d688

Browse files
committed
FFmpeg deep audit: comprehensive fixes and enhancements
Security fixes: - Aligned codec whitelists between validator and FFmpegCommandBuilder - Added professional codecs (prores, dnxhd, SVT-AV1, PCM variants) - Enhanced watermark handling with position presets and path validation Bug fixes: - Fixed atempo filter for speeds outside 0.5-2.0 range (now chains filters) - Fixed faststart flag only applied to MP4/MOV containers - Fixed subtitle handler to handle empty paths gracefully - Fixed CRF validation to allow lossless encoding (CRF 0-4) New capabilities: - Added two-pass encoding support with execute_two_pass method - Added thumbnail extraction operation (single, multiple, best, sprite modes) - Added concat operation handler (demuxer and filter modes) - Added tune parameter support (film, animation, grain, etc.) - Added H.264/H.265 level parameter support - Added reference frames, lookahead, and scene change threshold params - Added SVT-AV1 encoder selection support - Added 10-bit pixel formats (yuv420p10le, yuv422p10le, etc.) Production improvements: - Comprehensive parameter validation in validators.py - Enhanced error messages with allowed values - Resource management warnings for high-quality settings
1 parent febb186 commit 571d688

File tree

2 files changed

+556
-33
lines changed

2 files changed

+556
-33
lines changed

api/utils/validators.py

Lines changed: 171 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
611699
def 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

Comments
 (0)