diff --git a/api/utils/validators.py b/api/utils/validators.py index acba57e..052ad21 100644 --- a/api/utils/validators.py +++ b/api/utils/validators.py @@ -225,7 +225,8 @@ async def validate_output_path( def validate_operations(operations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Validate and normalize operations list with enhanced security checks.""" if not operations: - raise ValueError("Operations list cannot be empty") + # Empty operations list is valid - will use default transcoding + return [] max_ops = settings.MAX_OPERATIONS_PER_JOB 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] validated_op = validate_watermark_operation(op) elif op_type == "filter": validated_op = validate_filter_operation(op) - elif op_type == "stream": + elif op_type in ("stream", "streaming"): validated_op = validate_stream_operation(op) elif op_type == "transcode": validated_op = validate_transcode_operation(op) + elif op_type == "scale": + validated_op = validate_scale_operation(op) + elif op_type == "crop": + validated_op = validate_crop_operation(op) + elif op_type == "rotate": + validated_op = validate_rotate_operation(op) + elif op_type == "flip": + validated_op = validate_flip_operation(op) + elif op_type == "audio": + validated_op = validate_audio_operation(op) + elif op_type == "subtitle": + validated_op = validate_subtitle_operation(op) + elif op_type == "concat": + validated_op = validate_concat_operation(op) else: raise ValueError(f"Unknown operation type: {op_type}") @@ -381,31 +396,54 @@ def validate_watermark_operation(op: Dict[str, Any]) -> Dict[str, Any]: def validate_filter_operation(op: Dict[str, Any]) -> Dict[str, Any]: """Validate filter operation.""" - if "name" not in op: - raise ValueError("Filter operation requires 'name' field") - allowed_filters = { "denoise", "deinterlace", "stabilize", "sharpen", "blur", - "brightness", "contrast", "saturation", "hue", "eq" - } - - filter_name = op["name"] - if filter_name not in allowed_filters: - raise ValueError(f"Unknown filter: {filter_name}") - - return { - "type": "filter", - "name": filter_name, - "params": op.get("params", {}), + "brightness", "contrast", "saturation", "hue", "eq", "gamma", + "fade_in", "fade_out", "speed" } + validated = {"type": "filter"} + + # Support named filter or direct params + if "name" in op: + filter_name = op["name"] + if filter_name not in allowed_filters: + raise ValueError(f"Unknown filter: {filter_name}") + validated["name"] = filter_name + validated["params"] = op.get("params", {}) + else: + # Support direct filter params without name + for key in op: + if key != "type" and key in allowed_filters: + validated[key] = op[key] + + # Validate specific filter parameters + if "brightness" in validated: + b = validated["brightness"] + if not isinstance(b, (int, float)) or b < -1 or b > 1: + raise ValueError("Brightness must be between -1 and 1") + if "contrast" in validated: + c = validated["contrast"] + if not isinstance(c, (int, float)) or c < 0 or c > 4: + raise ValueError("Contrast must be between 0 and 4") + if "saturation" in validated: + s = validated["saturation"] + if not isinstance(s, (int, float)) or s < 0 or s > 3: + raise ValueError("Saturation must be between 0 and 3") + if "speed" in validated: + sp = validated["speed"] + if not isinstance(sp, (int, float)) or sp < 0.25 or sp > 4: + raise ValueError("Speed must be between 0.25 and 4") + + return validated + def validate_stream_operation(op: Dict[str, Any]) -> Dict[str, Any]: """Validate streaming operation.""" stream_format = op.get("format", "hls").lower() if stream_format not in ["hls", "dash"]: raise ValueError(f"Unknown streaming format: {stream_format}") - + return { "type": "stream", "format": stream_format, @@ -414,6 +452,186 @@ def validate_stream_operation(op: Dict[str, Any]) -> Dict[str, Any]: } +def validate_scale_operation(op: Dict[str, Any]) -> Dict[str, Any]: + """Validate scale operation.""" + validated = {"type": "scale"} + + # Width and height + if "width" in op: + width = op["width"] + if width != "auto" and width != -1: + if not isinstance(width, (int, float)): + raise ValueError("Width must be a number or 'auto'") + width = int(width) + if width < 32 or width > 7680: + raise ValueError("Width out of valid range (32-7680)") + if width % 2 != 0: + raise ValueError("Width must be even number") + validated["width"] = width + + if "height" in op: + height = op["height"] + if height != "auto" and height != -1: + if not isinstance(height, (int, float)): + raise ValueError("Height must be a number or 'auto'") + height = int(height) + if height < 32 or height > 4320: + raise ValueError("Height out of valid range (32-4320)") + if height % 2 != 0: + raise ValueError("Height must be even number") + validated["height"] = height + + # Scaling algorithm + if "algorithm" in op: + allowed_algorithms = {"lanczos", "bicubic", "bilinear", "neighbor", "area", "fast_bilinear"} + if op["algorithm"] not in allowed_algorithms: + raise ValueError(f"Invalid scaling algorithm: {op['algorithm']}") + validated["algorithm"] = op["algorithm"] + + return validated + + +def validate_crop_operation(op: Dict[str, Any]) -> Dict[str, Any]: + """Validate crop operation.""" + validated = {"type": "crop"} + + for field in ["width", "height", "x", "y"]: + if field in op: + value = op[field] + if isinstance(value, str): + # Allow FFmpeg expressions like 'iw', 'ih', 'iw/2' + if not re.match(r'^[a-zA-Z0-9\+\-\*\/\(\)\.]+$', value): + raise ValueError(f"Invalid {field} expression: {value}") + validated[field] = value + elif isinstance(value, (int, float)): + if value < 0: + raise ValueError(f"{field} must be non-negative") + validated[field] = int(value) if field in ["x", "y"] else value + else: + raise ValueError(f"{field} must be a number or expression") + + return validated + + +def validate_rotate_operation(op: Dict[str, Any]) -> Dict[str, Any]: + """Validate rotate operation.""" + validated = {"type": "rotate"} + + if "angle" in op: + angle = op["angle"] + if not isinstance(angle, (int, float)): + raise ValueError("Angle must be a number") + # Normalize to -360 to 360 range + angle = angle % 360 + if angle > 180: + angle -= 360 + validated["angle"] = angle + + return validated + + +def validate_flip_operation(op: Dict[str, Any]) -> Dict[str, Any]: + """Validate flip operation.""" + validated = {"type": "flip"} + + direction = op.get("direction", "horizontal") + if direction not in ["horizontal", "vertical", "both"]: + raise ValueError(f"Invalid flip direction: {direction}") + validated["direction"] = direction + + return validated + + +def validate_audio_operation(op: Dict[str, Any]) -> Dict[str, Any]: + """Validate audio processing operation.""" + validated = {"type": "audio"} + + # Volume adjustment + if "volume" in op: + volume = op["volume"] + if isinstance(volume, (int, float)): + if volume < 0 or volume > 10: + raise ValueError("Volume must be between 0 and 10") + validated["volume"] = volume + elif isinstance(volume, str): + # Allow dB notation like "-3dB" or "2dB" + if not re.match(r'^-?\d+(\.\d+)?dB$', volume): + raise ValueError("Volume string must be in dB format (e.g., '-3dB')") + validated["volume"] = volume + + # Normalization + if "normalize" in op: + validated["normalize"] = bool(op["normalize"]) + if "normalize_type" in op: + if op["normalize_type"] not in ["loudnorm", "dynaudnorm"]: + raise ValueError("Invalid normalize type") + validated["normalize_type"] = op["normalize_type"] + + # Sample rate + if "sample_rate" in op: + sr = op["sample_rate"] + allowed_sample_rates = [8000, 11025, 16000, 22050, 32000, 44100, 48000, 96000] + if sr not in allowed_sample_rates: + raise ValueError(f"Invalid sample rate: {sr}") + validated["sample_rate"] = sr + + # Channels + if "channels" in op: + channels = op["channels"] + if channels not in [1, 2, 6, 8]: + raise ValueError("Channels must be 1, 2, 6, or 8") + validated["channels"] = channels + + return validated + + +def validate_subtitle_operation(op: Dict[str, Any]) -> Dict[str, Any]: + """Validate subtitle operation.""" + validated = {"type": "subtitle"} + + if "path" not in op: + raise ValueError("Subtitle operation requires 'path' field") + + path = op["path"] + # Validate subtitle file extension + allowed_ext = {".srt", ".ass", ".ssa", ".vtt", ".sub"} + ext = Path(path).suffix.lower() + if ext not in allowed_ext: + raise ValueError(f"Invalid subtitle format: {ext}") + + validated["path"] = path + + # Optional styling + if "style" in op: + validated["style"] = op["style"] + + return validated + + +def validate_concat_operation(op: Dict[str, Any]) -> Dict[str, Any]: + """Validate concatenation operation.""" + validated = {"type": "concat"} + + if "inputs" not in op: + raise ValueError("Concat operation requires 'inputs' field with list of files") + + inputs = op["inputs"] + if not isinstance(inputs, list) or len(inputs) < 2: + raise ValueError("Concat requires at least 2 input files") + + if len(inputs) > 100: + raise ValueError("Too many inputs for concat (max 100)") + + validated["inputs"] = inputs + + # Demuxer mode (safer) vs filter mode (more flexible) + validated["mode"] = op.get("mode", "demuxer") + if validated["mode"] not in ["demuxer", "filter"]: + raise ValueError("Concat mode must be 'demuxer' or 'filter'") + + return validated + + def validate_transcode_operation(op: Dict[str, Any]) -> Dict[str, Any]: """Validate transcode operation with enhanced security checks.""" validated = {"type": "transcode"} diff --git a/worker/utils/ffmpeg.py b/worker/utils/ffmpeg.py index 2e77db3..be34eb5 100644 --- a/worker/utils/ffmpeg.py +++ b/worker/utils/ffmpeg.py @@ -129,7 +129,26 @@ class FFmpegCommandBuilder: } ALLOWED_FILTERS = { - 'scale', 'crop', 'overlay', 'eq', 'hqdn3d', 'unsharp', 'format', 'colorchannelmixer' + # Video scaling/transform + 'scale', 'crop', 'overlay', 'pad', 'setsar', 'setdar', 'transpose', 'hflip', 'vflip', 'rotate', + # Color/quality + 'eq', 'hqdn3d', 'unsharp', 'format', 'colorchannelmixer', 'lut3d', 'curves', 'lutyuv', 'lutrgb', + # Deinterlacing + 'yadif', 'bwdif', 'w3fdif', 'nnedi', + # Frame rate/timing + 'fps', 'framerate', 'trim', 'atrim', 'setpts', 'asetpts', + # Concatenation + 'concat', 'split', 'asplit', + # Audio + 'volume', 'loudnorm', 'dynaudnorm', 'aresample', 'channelmap', 'pan', 'amerge', 'amix', 'atempo', + # Effects + 'fade', 'afade', 'drawtext', 'subtitles', 'ass', 'boxblur', 'gblur', 'smartblur', + # Stabilization + 'vidstabdetect', 'vidstabtransform', 'deshake', + # Thumbnails + 'thumbnail', 'select', 'tile', 'palettegen', 'paletteuse', 'zoompan', + # HDR/color space + 'zscale', 'tonemap', 'colorspace', 'colormatrix' } ALLOWED_PRESETS = { @@ -178,11 +197,14 @@ def build_command(self, input_path: str, output_path: str, # Add operations video_filters = [] audio_filters = [] - + for operation in operations: op_type = operation.get('type') + # Support both flat and nested params structure params = operation.get('params', {}) - + if not params: + params = {k: v for k, v in operation.items() if k != 'type'} + if op_type == 'transcode': cmd.extend(self._handle_transcode(params)) elif op_type == 'trim': @@ -190,11 +212,25 @@ def build_command(self, input_path: str, output_path: str, elif op_type == 'watermark': video_filters.append(self._handle_watermark(params)) elif op_type == 'filter': - video_filters.extend(self._handle_filters(params)) + vf, af = self._handle_filters(params) + video_filters.extend(vf) + audio_filters.extend(af) elif op_type == 'stream_map': cmd.extend(self._handle_stream_map(params)) - elif op_type == 'streaming': + elif op_type in ('streaming', 'stream'): cmd.extend(self._handle_streaming(params)) + elif op_type == 'scale': + video_filters.append(self._handle_scale(params)) + elif op_type == 'crop': + video_filters.append(self._handle_crop(params)) + elif op_type == 'rotate': + video_filters.append(self._handle_rotate(params)) + elif op_type == 'flip': + video_filters.append(self._handle_flip(params)) + elif op_type == 'audio': + audio_filters.extend(self._handle_audio(params)) + elif op_type == 'subtitle': + video_filters.append(self._handle_subtitle(params)) # Add video filters if video_filters: @@ -268,24 +304,35 @@ def _validate_options(self, options: Dict[str, Any]): def _validate_operations(self, operations: List[Dict[str, Any]]): """Validate operations list for security.""" + if operations is None: + return # None is valid, treated as empty if not isinstance(operations, list): raise FFmpegCommandError("Operations must be a list") - - allowed_operation_types = {'transcode', 'trim', 'watermark', 'filter', 'stream_map', 'streaming'} - + if not operations: + return # Empty list is valid + + allowed_operation_types = { + 'transcode', 'trim', 'watermark', 'filter', 'stream_map', 'streaming', 'stream', + 'scale', 'crop', 'rotate', 'flip', 'audio', 'subtitle', 'concat', 'thumbnail' + } + for i, operation in enumerate(operations): if not isinstance(operation, dict): raise FFmpegCommandError(f"Operation {i} must be a dictionary") - + op_type = operation.get('type') if op_type not in allowed_operation_types: raise FFmpegCommandError(f"Unknown operation type: {op_type}") - - # Validate operation parameters + + # Support both flat params and nested 'params' structure params = operation.get('params', {}) + if not params: + # Flat structure: extract params from operation itself + params = {k: v for k, v in operation.items() if k != 'type'} + if not isinstance(params, dict): raise FFmpegCommandError(f"Operation {i} params must be a dictionary") - + self._validate_operation_params(op_type, params) def _validate_operation_params(self, op_type: str, params: Dict[str, Any]): @@ -533,12 +580,13 @@ def _handle_watermark(self, params: Dict[str, Any]) -> str: return overlay_filter - def _handle_filters(self, params: Dict[str, Any]) -> List[str]: - """Handle video filters.""" - filters = [] - - # Color correction - if 'brightness' in params or 'contrast' in params or 'saturation' in params: + def _handle_filters(self, params: Dict[str, Any]) -> Tuple[List[str], List[str]]: + """Handle video and audio filters. Returns (video_filters, audio_filters).""" + video_filters = [] + audio_filters = [] + + # Color correction (eq filter) + if 'brightness' in params or 'contrast' in params or 'saturation' in params or 'gamma' in params: eq_params = [] if 'brightness' in params: eq_params.append(f"brightness={params['brightness']}") @@ -546,17 +594,173 @@ def _handle_filters(self, params: Dict[str, Any]) -> List[str]: eq_params.append(f"contrast={params['contrast']}") if 'saturation' in params: eq_params.append(f"saturation={params['saturation']}") - filters.append(f"eq={':'.join(eq_params)}") - + if 'gamma' in params: + eq_params.append(f"gamma={params['gamma']}") + video_filters.append(f"eq={':'.join(eq_params)}") + # Denoising if params.get('denoise'): - filters.append(f"hqdn3d={params['denoise']}") - + strength = params['denoise'] + if isinstance(strength, bool): + video_filters.append("hqdn3d") + else: + video_filters.append(f"hqdn3d={strength}") + # Sharpening if params.get('sharpen'): - filters.append(f"unsharp=5:5:{params['sharpen']}:5:5:{params['sharpen']}") - + strength = params['sharpen'] + video_filters.append(f"unsharp=5:5:{strength}:5:5:{strength}") + + # Blur + if params.get('blur'): + strength = params['blur'] + video_filters.append(f"boxblur={strength}") + + # Deinterlacing + if params.get('deinterlace'): + mode = params.get('deinterlace_mode', 'send_frame') + video_filters.append(f"yadif=mode={mode}") + + # Stabilization + if params.get('stabilize'): + video_filters.append("vidstabtransform=smoothing=10") + + # Fade in/out + if params.get('fade_in'): + video_filters.append(f"fade=t=in:st=0:d={params['fade_in']}") + audio_filters.append(f"afade=t=in:st=0:d={params['fade_in']}") + if params.get('fade_out'): + # Note: fade_out duration - actual position needs video duration + video_filters.append(f"fade=t=out:d={params['fade_out']}") + audio_filters.append(f"afade=t=out:d={params['fade_out']}") + + # Speed adjustment + if params.get('speed'): + speed = params['speed'] + video_filters.append(f"setpts={1/speed}*PTS") + audio_filters.append(f"atempo={speed}") + + # Named filter support (direct filter specification) + if 'name' in params: + filter_name = params['name'] + filter_params_dict = params.get('params', {}) + if filter_name in self.ALLOWED_FILTERS: + if filter_params_dict: + filter_str = f"{filter_name}=" + ':'.join(f"{k}={v}" for k, v in filter_params_dict.items()) + else: + filter_str = filter_name + video_filters.append(filter_str) + + return video_filters, audio_filters + + def _handle_scale(self, params: Dict[str, Any]) -> str: + """Handle scale operation.""" + width = params.get('width', -1) + height = params.get('height', -1) + algorithm = params.get('algorithm', 'lanczos') + + # Handle special values + if width == 'auto' or width == -1: + width = -1 + if height == 'auto' or height == -1: + height = -1 + + # Build scale filter + scale_filter = f"scale={width}:{height}" + if algorithm: + scale_filter += f":flags={algorithm}" + + return scale_filter + + def _handle_crop(self, params: Dict[str, Any]) -> str: + """Handle crop operation.""" + width = params.get('width', 'iw') + height = params.get('height', 'ih') + x = params.get('x', 0) + y = params.get('y', 0) + + return f"crop={width}:{height}:{x}:{y}" + + def _handle_rotate(self, params: Dict[str, Any]) -> str: + """Handle rotation operation.""" + angle = params.get('angle', 0) + + # Handle common angles with transpose + if angle == 90: + return "transpose=1" + elif angle == -90 or angle == 270: + return "transpose=2" + elif angle == 180: + return "transpose=1,transpose=1" + else: + # Arbitrary angle rotation + return f"rotate={angle}*PI/180" + + def _handle_flip(self, params: Dict[str, Any]) -> str: + """Handle flip operation.""" + direction = params.get('direction', 'horizontal') + + if direction == 'horizontal': + return "hflip" + elif direction == 'vertical': + return "vflip" + elif direction == 'both': + return "hflip,vflip" + else: + return "hflip" + + def _handle_audio(self, params: Dict[str, Any]) -> List[str]: + """Handle audio processing operations.""" + filters = [] + + # Volume adjustment + if 'volume' in params: + volume = params['volume'] + if isinstance(volume, (int, float)): + filters.append(f"volume={volume}") + elif isinstance(volume, str): + filters.append(f"volume={volume}") + + # Audio normalization + if params.get('normalize'): + norm_type = params.get('normalize_type', 'loudnorm') + if norm_type == 'loudnorm': + # EBU R128 loudness normalization + i = params.get('target_loudness', -24) + tp = params.get('true_peak', -2) + lra = params.get('loudness_range', 7) + filters.append(f"loudnorm=I={i}:TP={tp}:LRA={lra}") + elif norm_type == 'dynaudnorm': + filters.append("dynaudnorm") + + # Sample rate conversion + if 'sample_rate' in params: + filters.append(f"aresample={params['sample_rate']}") + + # Channel layout + if 'channels' in params: + channels = params['channels'] + if channels == 1: + filters.append("pan=mono|c0=0.5*c0+0.5*c1") + elif channels == 2: + filters.append("pan=stereo|c0=c0|c1=c1") + return filters + + def _handle_subtitle(self, params: Dict[str, Any]) -> str: + """Handle subtitle overlay.""" + subtitle_path = params.get('path', '') + if not subtitle_path: + return "" + + # Validate subtitle path + self._validate_paths(subtitle_path, subtitle_path) + + # Determine subtitle type + if subtitle_path.endswith('.ass') or subtitle_path.endswith('.ssa'): + return f"ass={subtitle_path}" + else: + return f"subtitles={subtitle_path}" def _handle_stream_map(self, params: Dict[str, Any]) -> List[str]: """Handle stream mapping."""