Skip to content

Commit 994a198

Browse files
Merge pull request #37 from rendiffdev/enhance-ffmpeg-operations
Enhance FFmpeg operations for full transcoding capabilities
2 parents 46d1524 + 9346936 commit 994a198

File tree

2 files changed

+463
-41
lines changed

2 files changed

+463
-41
lines changed

api/utils/validators.py

Lines changed: 235 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,8 @@ async def validate_output_path(
225225
def validate_operations(operations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
226226
"""Validate and normalize operations list with enhanced security checks."""
227227
if not operations:
228-
raise ValueError("Operations list cannot be empty")
228+
# Empty operations list is valid - will use default transcoding
229+
return []
229230

230231
max_ops = settings.MAX_OPERATIONS_PER_JOB
231232
if len(operations) > max_ops: # Prevent DOS through too many operations
@@ -256,10 +257,24 @@ def validate_operations(operations: List[Dict[str, Any]]) -> List[Dict[str, Any]
256257
validated_op = validate_watermark_operation(op)
257258
elif op_type == "filter":
258259
validated_op = validate_filter_operation(op)
259-
elif op_type == "stream":
260+
elif op_type in ("stream", "streaming"):
260261
validated_op = validate_stream_operation(op)
261262
elif op_type == "transcode":
262263
validated_op = validate_transcode_operation(op)
264+
elif op_type == "scale":
265+
validated_op = validate_scale_operation(op)
266+
elif op_type == "crop":
267+
validated_op = validate_crop_operation(op)
268+
elif op_type == "rotate":
269+
validated_op = validate_rotate_operation(op)
270+
elif op_type == "flip":
271+
validated_op = validate_flip_operation(op)
272+
elif op_type == "audio":
273+
validated_op = validate_audio_operation(op)
274+
elif op_type == "subtitle":
275+
validated_op = validate_subtitle_operation(op)
276+
elif op_type == "concat":
277+
validated_op = validate_concat_operation(op)
263278
else:
264279
raise ValueError(f"Unknown operation type: {op_type}")
265280

@@ -381,31 +396,54 @@ def validate_watermark_operation(op: Dict[str, Any]) -> Dict[str, Any]:
381396

382397
def validate_filter_operation(op: Dict[str, Any]) -> Dict[str, Any]:
383398
"""Validate filter operation."""
384-
if "name" not in op:
385-
raise ValueError("Filter operation requires 'name' field")
386-
387399
allowed_filters = {
388400
"denoise", "deinterlace", "stabilize", "sharpen", "blur",
389-
"brightness", "contrast", "saturation", "hue", "eq"
390-
}
391-
392-
filter_name = op["name"]
393-
if filter_name not in allowed_filters:
394-
raise ValueError(f"Unknown filter: {filter_name}")
395-
396-
return {
397-
"type": "filter",
398-
"name": filter_name,
399-
"params": op.get("params", {}),
401+
"brightness", "contrast", "saturation", "hue", "eq", "gamma",
402+
"fade_in", "fade_out", "speed"
400403
}
401404

405+
validated = {"type": "filter"}
406+
407+
# Support named filter or direct params
408+
if "name" in op:
409+
filter_name = op["name"]
410+
if filter_name not in allowed_filters:
411+
raise ValueError(f"Unknown filter: {filter_name}")
412+
validated["name"] = filter_name
413+
validated["params"] = op.get("params", {})
414+
else:
415+
# Support direct filter params without name
416+
for key in op:
417+
if key != "type" and key in allowed_filters:
418+
validated[key] = op[key]
419+
420+
# Validate specific filter parameters
421+
if "brightness" in validated:
422+
b = validated["brightness"]
423+
if not isinstance(b, (int, float)) or b < -1 or b > 1:
424+
raise ValueError("Brightness must be between -1 and 1")
425+
if "contrast" in validated:
426+
c = validated["contrast"]
427+
if not isinstance(c, (int, float)) or c < 0 or c > 4:
428+
raise ValueError("Contrast must be between 0 and 4")
429+
if "saturation" in validated:
430+
s = validated["saturation"]
431+
if not isinstance(s, (int, float)) or s < 0 or s > 3:
432+
raise ValueError("Saturation must be between 0 and 3")
433+
if "speed" in validated:
434+
sp = validated["speed"]
435+
if not isinstance(sp, (int, float)) or sp < 0.25 or sp > 4:
436+
raise ValueError("Speed must be between 0.25 and 4")
437+
438+
return validated
439+
402440

403441
def validate_stream_operation(op: Dict[str, Any]) -> Dict[str, Any]:
404442
"""Validate streaming operation."""
405443
stream_format = op.get("format", "hls").lower()
406444
if stream_format not in ["hls", "dash"]:
407445
raise ValueError(f"Unknown streaming format: {stream_format}")
408-
446+
409447
return {
410448
"type": "stream",
411449
"format": stream_format,
@@ -414,6 +452,186 @@ def validate_stream_operation(op: Dict[str, Any]) -> Dict[str, Any]:
414452
}
415453

416454

455+
def validate_scale_operation(op: Dict[str, Any]) -> Dict[str, Any]:
456+
"""Validate scale operation."""
457+
validated = {"type": "scale"}
458+
459+
# Width and height
460+
if "width" in op:
461+
width = op["width"]
462+
if width != "auto" and width != -1:
463+
if not isinstance(width, (int, float)):
464+
raise ValueError("Width must be a number or 'auto'")
465+
width = int(width)
466+
if width < 32 or width > 7680:
467+
raise ValueError("Width out of valid range (32-7680)")
468+
if width % 2 != 0:
469+
raise ValueError("Width must be even number")
470+
validated["width"] = width
471+
472+
if "height" in op:
473+
height = op["height"]
474+
if height != "auto" and height != -1:
475+
if not isinstance(height, (int, float)):
476+
raise ValueError("Height must be a number or 'auto'")
477+
height = int(height)
478+
if height < 32 or height > 4320:
479+
raise ValueError("Height out of valid range (32-4320)")
480+
if height % 2 != 0:
481+
raise ValueError("Height must be even number")
482+
validated["height"] = height
483+
484+
# Scaling algorithm
485+
if "algorithm" in op:
486+
allowed_algorithms = {"lanczos", "bicubic", "bilinear", "neighbor", "area", "fast_bilinear"}
487+
if op["algorithm"] not in allowed_algorithms:
488+
raise ValueError(f"Invalid scaling algorithm: {op['algorithm']}")
489+
validated["algorithm"] = op["algorithm"]
490+
491+
return validated
492+
493+
494+
def validate_crop_operation(op: Dict[str, Any]) -> Dict[str, Any]:
495+
"""Validate crop operation."""
496+
validated = {"type": "crop"}
497+
498+
for field in ["width", "height", "x", "y"]:
499+
if field in op:
500+
value = op[field]
501+
if isinstance(value, str):
502+
# Allow FFmpeg expressions like 'iw', 'ih', 'iw/2'
503+
if not re.match(r'^[a-zA-Z0-9\+\-\*\/\(\)\.]+$', value):
504+
raise ValueError(f"Invalid {field} expression: {value}")
505+
validated[field] = value
506+
elif isinstance(value, (int, float)):
507+
if value < 0:
508+
raise ValueError(f"{field} must be non-negative")
509+
validated[field] = int(value) if field in ["x", "y"] else value
510+
else:
511+
raise ValueError(f"{field} must be a number or expression")
512+
513+
return validated
514+
515+
516+
def validate_rotate_operation(op: Dict[str, Any]) -> Dict[str, Any]:
517+
"""Validate rotate operation."""
518+
validated = {"type": "rotate"}
519+
520+
if "angle" in op:
521+
angle = op["angle"]
522+
if not isinstance(angle, (int, float)):
523+
raise ValueError("Angle must be a number")
524+
# Normalize to -360 to 360 range
525+
angle = angle % 360
526+
if angle > 180:
527+
angle -= 360
528+
validated["angle"] = angle
529+
530+
return validated
531+
532+
533+
def validate_flip_operation(op: Dict[str, Any]) -> Dict[str, Any]:
534+
"""Validate flip operation."""
535+
validated = {"type": "flip"}
536+
537+
direction = op.get("direction", "horizontal")
538+
if direction not in ["horizontal", "vertical", "both"]:
539+
raise ValueError(f"Invalid flip direction: {direction}")
540+
validated["direction"] = direction
541+
542+
return validated
543+
544+
545+
def validate_audio_operation(op: Dict[str, Any]) -> Dict[str, Any]:
546+
"""Validate audio processing operation."""
547+
validated = {"type": "audio"}
548+
549+
# Volume adjustment
550+
if "volume" in op:
551+
volume = op["volume"]
552+
if isinstance(volume, (int, float)):
553+
if volume < 0 or volume > 10:
554+
raise ValueError("Volume must be between 0 and 10")
555+
validated["volume"] = volume
556+
elif isinstance(volume, str):
557+
# Allow dB notation like "-3dB" or "2dB"
558+
if not re.match(r'^-?\d+(\.\d+)?dB$', volume):
559+
raise ValueError("Volume string must be in dB format (e.g., '-3dB')")
560+
validated["volume"] = volume
561+
562+
# Normalization
563+
if "normalize" in op:
564+
validated["normalize"] = bool(op["normalize"])
565+
if "normalize_type" in op:
566+
if op["normalize_type"] not in ["loudnorm", "dynaudnorm"]:
567+
raise ValueError("Invalid normalize type")
568+
validated["normalize_type"] = op["normalize_type"]
569+
570+
# Sample rate
571+
if "sample_rate" in op:
572+
sr = op["sample_rate"]
573+
allowed_sample_rates = [8000, 11025, 16000, 22050, 32000, 44100, 48000, 96000]
574+
if sr not in allowed_sample_rates:
575+
raise ValueError(f"Invalid sample rate: {sr}")
576+
validated["sample_rate"] = sr
577+
578+
# Channels
579+
if "channels" in op:
580+
channels = op["channels"]
581+
if channels not in [1, 2, 6, 8]:
582+
raise ValueError("Channels must be 1, 2, 6, or 8")
583+
validated["channels"] = channels
584+
585+
return validated
586+
587+
588+
def validate_subtitle_operation(op: Dict[str, Any]) -> Dict[str, Any]:
589+
"""Validate subtitle operation."""
590+
validated = {"type": "subtitle"}
591+
592+
if "path" not in op:
593+
raise ValueError("Subtitle operation requires 'path' field")
594+
595+
path = op["path"]
596+
# Validate subtitle file extension
597+
allowed_ext = {".srt", ".ass", ".ssa", ".vtt", ".sub"}
598+
ext = Path(path).suffix.lower()
599+
if ext not in allowed_ext:
600+
raise ValueError(f"Invalid subtitle format: {ext}")
601+
602+
validated["path"] = path
603+
604+
# Optional styling
605+
if "style" in op:
606+
validated["style"] = op["style"]
607+
608+
return validated
609+
610+
611+
def validate_concat_operation(op: Dict[str, Any]) -> Dict[str, Any]:
612+
"""Validate concatenation operation."""
613+
validated = {"type": "concat"}
614+
615+
if "inputs" not in op:
616+
raise ValueError("Concat operation requires 'inputs' field with list of files")
617+
618+
inputs = op["inputs"]
619+
if not isinstance(inputs, list) or len(inputs) < 2:
620+
raise ValueError("Concat requires at least 2 input files")
621+
622+
if len(inputs) > 100:
623+
raise ValueError("Too many inputs for concat (max 100)")
624+
625+
validated["inputs"] = inputs
626+
627+
# Demuxer mode (safer) vs filter mode (more flexible)
628+
validated["mode"] = op.get("mode", "demuxer")
629+
if validated["mode"] not in ["demuxer", "filter"]:
630+
raise ValueError("Concat mode must be 'demuxer' or 'filter'")
631+
632+
return validated
633+
634+
417635
def validate_transcode_operation(op: Dict[str, Any]) -> Dict[str, Any]:
418636
"""Validate transcode operation with enhanced security checks."""
419637
validated = {"type": "transcode"}

0 commit comments

Comments
 (0)