< Project-4.3 mpge4jpg > code 重写 python flask ffmpeg web应用 图片格式、大小转换,简单裁剪 视频格式转换 主要是压缩 占用9002端口

原因:

上一版的界面太简单,功能也是,如下图:素 

原文章发表于3个月前:<Project-4 2mp4jpg> Python Coding Flask应用:将视频与图片转换为MP4/JPG应用 主要用到 ffmpeg PIL

用它在处理图片时功能不够,还会用 ACDsee 去编辑。 其实就是从 Mid journey 下载图片,然后改大小,切个图做 logo,作应用的图标。一是在我的 Portal 上使用,二是在这里发文章当主图。

当前界面

也素,但菜单复杂显着牛B,主要使用 FFmpeg 实现。

一、图片转换功能(左)

  1. 支持 JPG, PNG, GIF, BMP, WebP 图片文件格式
  2. 有图片预览功能
  3. 在预览区可以裁剪图片:自由裁剪,或 1:1裁剪
  4. 图片的输入支持原格式、PNG JPG WebP 之间选择
  5. 上传图片是 PNG 格式,在输出格式会默认为 JPG
  6. 图片大小:原尺寸、一半儿、96x96(chrome browser logo)、自定义大小
  7. 在自定义大小中:支持大小写的 x 符号,* (星) 符号
  8. 可以预览转换后图片
  9. 转换后图片支持下载
  10. 显示原图片信息:分辨率、文件大小、格式
  11. 显示转换后的图片信息:分辨率、文件大小、格式
  12. 点击转换后,不清除原文件,可以继续重新操作,直到重新选择图片

演示:图片转换


二、视频转换功能(右)

  1. 支持转换的视频格式:MP4, AVI, MKV, MOV, WMV
  2. 文件最大支持 7GB (原本是 100MB, 做起来已经够慢的)
  3. 输出主要是为了小,格式支持:MP4 H.256, WebP(v8/v9),WebA(这个是音频),AVI 
  4. 压缩质量:高中低,对应于文件大质量好,中庸,低质小文件
  5. 多种分辨率在菜单中可以选择:原大小, 1/2大小, 1/4 大小,一些常见的参数 720P,1080P,480P,4K(有几个极少用,但参考时有,一起复制)
  6. 支持自定义分辨率
  7. 默认支持锁定视频“横纵比”
  8. 原视频支持预览播放
  9. 显示原视频信息:分辨率,时长,文件大小,视频格式,帧率FPS,比特率
  10. 转换后视频信息:同上

完整代码

1. 目录结构

APP:mpeg4jpg/
├── app.py             
├── converter.py       
├── static/            
│   ├── css/
│   │   └── style.css
│   ├── js/
│   │   ├── main.js
│   │   └── main-video.js
│   └── favicon.jpg
├── templates/         
│   └── index.html
├── cert.pem          # SSL certificate
└── key.pem           # SSL private key

2. app.py

from flask import (
    Flask, 
    request, 
    jsonify, 
    send_file, 
    render_template, 
    make_response
)
from werkzeug.utils import secure_filename
import os
from converter import ImageConverter, VideoConverter
import tempfile
import shutil
import base64
import json

app = Flask(__name__)

# Configuration
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024  # 100MB max file size
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp')
app.config['ALLOWED_IMAGE_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}
app.config['ALLOWED_VIDEO_EXTENSIONS'] = {'mp4', 'avi', 'mkv', 'mov', 'wmv'}

# Ensure temp directory exists
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

def allowed_file(filename, allowed_extensions):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in allowed_extensions

@app.route('/health', methods=['GET'])
def health_check():
    """Health check endpoint"""
    return jsonify({'status': 'healthy'}), 200

@app.route('/convert/image', methods=['POST'])
def convert_image():
    try:
        if 'file' not in request.files:
            return jsonify({'error': 'No file provided'}), 400
            
        file = request.files['file']
        if file.filename == '':
            return jsonify({'error': 'No file selected'}), 400
            
        if not allowed_file(file.filename, app.config['ALLOWED_IMAGE_EXTENSIONS']):
            return jsonify({'error': 'Invalid file type'}), 400

        with tempfile.TemporaryDirectory() as temp_dir:
            # Get original file extension
            original_ext = os.path.splitext(file.filename)[1][1:].lower()
            
            # Get output format, defaulting to original if not specified
            output_format = request.form.get('output_format', '').lower()
            if not output_format:
                output_format = original_ext
            
            if not output_format:
                output_format = 'jpg'  # Default to jpg if no extension found
            
            # Save input file
            input_path = os.path.join(temp_dir, f'input.{output_format}')
            file.save(input_path)
            
            # Define paths with proper extensions
            temp_crop_path = os.path.join(temp_dir, f'cropped.{output_format}')
            final_output_path = os.path.join(temp_dir, f'output.{output_format}')
            
            converter = ImageConverter()
            
            # Handle cropping if enabled
            current_input = input_path
            if 'crop_data' in request.form:
                try:
                    crop_data = json.loads(request.form['crop_data'])
                    converter.crop_image(input_path, temp_crop_path, crop_data)
                    current_input = temp_crop_path
                except json.JSONDecodeError:
                    return jsonify({'error': 'Invalid crop data format'}), 400
                except Exception as e:
                    return jsonify({'error': f'Crop failed: {str(e)}'}), 500

            try:
                # Handle resizing if specified
                resize_value = request.form.get('resize', '').strip()
                if resize_value:
                    converter.resize(current_input, final_output_path, resize_value)
                else:
                    converter.convert_format(current_input, final_output_path)
                
                # Read and return the processed image
                with open(final_output_path, 'rb') as f:
                    img_data = f.read()
                
                encoded_img = base64.b64encode(img_data).decode('utf-8')
                
                return jsonify({
                    'success': True,
                    'image': encoded_img,
                    'filename': f'output.{output_format}',
                    'type': f'image/{output_format}'
                })
                
            except Exception as e:
                return jsonify({'error': f'Image processing failed: {str(e)}'}), 500
            
    except Exception as e:
        return jsonify({'error': str(e)}), 500

@app.after_request
def after_request(response):
    response.headers.add('Access-Control-Allow-Origin', '*')
    response.headers.add('Access-Control-Allow-Headers', 'Content-Type')
    response.headers.add('Access-Control-Allow-Methods', 'GET,POST')
    return response

@app.route('/convert/video', methods=['POST'])
def convert_video():
    """
    Resize video
    Expects multipart/form-data with:
    - file: video file
    - resize: dimensions in format WIDTHxHEIGHT
    - maintain_aspect_ratio: true/false (optional, default: true)
    """
    try:
        if 'file' not in request.files:
            return jsonify({'error': 'No file provided'}), 400
            
        file = request.files['file']
        if file.filename == '':
            return jsonify({'error': 'No file selected'}), 400
            
        if not allowed_file(file.filename, app.config['ALLOWED_VIDEO_EXTENSIONS']):
            return jsonify({'error': 'Invalid file type'}), 400
            
        if 'resize' not in request.form:
            return jsonify({'error': 'Resize dimensions not provided'}), 400

        # Create temporary directory for processing
        with tempfile.TemporaryDirectory() as temp_dir:
            # Save uploaded file
            input_path = os.path.join(temp_dir, secure_filename(file.filename))
            file.save(input_path)
            
            output_filename = f'output{os.path.splitext(file.filename)[1]}'
            output_path = os.path.join(temp_dir, output_filename)
            
            # Process the video
            converter = VideoConverter()
            maintain_aspect_ratio = request.form.get('maintain_aspect_ratio', 'true').lower() == 'true'
            
            converter.resize(
                input_path,
                output_path,
                request.form['resize'],
                maintain_aspect_ratio
            )
            
            # Return the processed file
            return send_file(
                output_path,
                as_attachment=True,
                download_name=output_filename
            )
            
    except Exception as e:
        return jsonify({'error': str(e)}), 500

@app.route('/')
def index():
    """Serve the main page"""
    return render_template('index.html')

@app.errorhandler(413)
def too_large(e):
    return jsonify({'error': 'File too large'}), 413

if __name__ == '__main__':
    try:
        # Try to use SSL if certificates exist
        if os.path.exists('cert.pem') and os.path.exists('key.pem'):
            ssl_context = ('cert.pem', 'key.pem')
            app.run(host='0.0.0.0', port=9012, ssl_context=ssl_context)
        else:
            # Fall back to non-SSL if certificates don't exist
            app.run(host='0.0.0.0', port=9012)
    except Exception as e:
        print(f"Error starting server: {str(e)}")

3. converter.py

from PIL import Image
import subprocess
import os
import tempfile
import shutil
import json

class ImageConverter:
    """Handles image conversion and resizing operations"""
    
    def __init__(self):
        """Initialize image converter with supported formats"""
        self.supported_formats = {
            'jpg': 'JPEG',
            'jpeg': 'JPEG',
            'png': 'PNG',
            'webp': 'WEBP',
            'gif': 'GIF',
            'bmp': 'BMP'
        }
    
    def crop_image(self, input_path, output_path, crop_data):
        """
        Crop image according to specified coordinates
    
        Args:
            input_path: Path to input image file
            output_path: Path where cropped image should be saved
            crop_data: Dict with x, y, width, height for cropping
        """
        temp_file = None
        img = None
        try:
            if not os.path.exists(input_path):
                raise Exception(f"Input file not found: {input_path}")
        
            # Get output format from the output path
            output_format = os.path.splitext(output_path)[1][1:].lower()
            if not output_format:
                raise Exception("Output path must have a valid extension")
        
            # Create temp file with unique name
            temp_dir = os.path.dirname(output_path)
            temp_file = os.path.join(temp_dir, f'temp_{os.urandom(8).hex()}.{output_format}')
        
            # Open and process image
            img = Image.open(input_path)
        
            # Validate crop dimensions
            if not all(key in crop_data for key in ['x', 'y', 'width', 'height']):
                raise ValueError("Invalid crop data: missing coordinates")
            
            if any(val < 0 for val in crop_data.values()):
                raise ValueError("Crop coordinates cannot be negative")
        
            # Convert RGBA to RGB if saving as JPEG
            if output_format.lower() in ('jpg', 'jpeg') and img.mode == 'RGBA':
                img = img.convert('RGB')
        
            # Perform crop
            cropped = img.crop((
                int(crop_data['x']),
                int(crop_data['y']),
                int(crop_data['x'] + crop_data['width']),
                int(crop_data['y'] + crop_data['height'])
            ))
        
            # Get format-specific save parameters
            save_kwargs = self._get_save_kwargs(output_path)
        
            # Save directly to temp file
            cropped.save(temp_file, **save_kwargs)
        
            # Close image handles explicitly
            img.close()
            cropped.close()
        
            # Move temp file to final destination after all handles are closed
            if os.path.exists(output_path):
                os.remove(output_path)
            shutil.move(temp_file, output_path)
        
        except Exception as e:
            raise Exception(f"Image cropping failed: {str(e)}")
        
        finally:
            # Clean up
            if img:
                try:
                    img.close()
                except:
                    pass
                
            # Clean up temp file if it still exists
            if temp_file and os.path.exists(temp_file):
                try:
                    os.remove(temp_file)
                except:
                    pass
    
    def get_image_dimensions(self, input_path):
        """
        Get the dimensions of an image
        
        Args:
            input_path: Path to image file
            
        Returns:
            tuple: (width, height)
            
        Raises:
            Exception: If image cannot be opened or is invalid
        """
        try:
            with Image.open(input_path) as img:
                return img.size
        except Exception as e:
            raise Exception(f"Failed to get image dimensions: {str(e)}")

    def calculate_half_size(self, input_path):
        """
        Calculate half size dimensions maintaining aspect ratio
        
        Args:
            input_path: Path to image file
            
        Returns:
            tuple: (width, height) at half size
        """
        width, height = self.get_image_dimensions(input_path)
        return (width // 2, height // 2)
    
    def _get_save_kwargs(self, output_path):
        """Get optimal save parameters based on output format"""
        save_kwargs = {}
        if output_path.lower().endswith(('.jpg', '.jpeg')):
            save_kwargs = {
                'quality': 95,
                'optimize': True,
                'progressive': True
            }
        elif output_path.lower().endswith('.png'):
            save_kwargs = {
                'optimize': True,
                'compress_level': 9
            }
        elif output_path.lower().endswith('.webp'):
            save_kwargs = {
                'quality': 90,
                'method': 6,
                'lossless': False
            }
        return save_kwargs
    
    def convert_format(self, input_path, output_path):
        """
        Convert image from one format to another with optimizations
    
        Args:
            input_path: Source image path
            output_path: Destination path with desired format extension
            
        Raises:
            Exception: If conversion fails
        """
        if not os.path.exists(input_path):
            raise Exception(f"Input file not found: {input_path}")
        
        # Get output format from the output path
        output_format = os.path.splitext(output_path)[1][1:].lower()
        if not output_format:
            raise Exception("Output path must have a valid extension")

        temp_file = None
        img = None
        try:
            img = Image.open(input_path)

            # Create temp file with unique name
            temp_dir = os.path.dirname(output_path)
            temp_file = os.path.join(temp_dir, f'temp_convert_{os.urandom(8).hex()}.{output_format}')

            # Handle color mode conversion
            if output_format.lower() in ('jpg', 'jpeg') and img.mode == 'RGBA':
                img = img.convert('RGB')
            elif output_format.lower() == 'png' and img.mode != 'RGBA':
                img = img.convert('RGBA')

            # Get format-specific save parameters
            save_kwargs = self._get_save_kwargs(output_path)

            # Save with optimizations
            img.save(temp_file, **save_kwargs)

            # close image handles
            img.close()

            # Move to final destination
            if os.path.exists(output_path):
                os.remove(output_path)
            shutil.move(temp_file, output_path)

        except Exception as e:
            raise Exception(f"Image conversion failed: {str(e)}")
        
        finally:
            # Clean up
            if img:
                try:
                    img.close()
                except:
                    pass
            
            # Clean up temp file if it still exists
            if temp_file and os.path.exists(temp_file):
                try:
                    os.remove(temp_file)
                except:
                    pass

    def resize(self, input_path, output_path, dimensions):
        """
        Resize image based on dimensions or preset options
    
        Args:
            input_path (str): Path to input image file
            output_path (str): Path where resized image should be saved
            dimensions (str): Either "half", "96x96", or custom "WxH" format
        """
        if not os.path.exists(input_path):
            raise Exception(f"Input file not found: {input_path}")

        # Get output format from the output path
        output_format = os.path.splitext(output_path)[1][1:].lower()
        if not output_format:
            raise Exception("Output path must have a valid extension")

        temp_file = None
        img = None
        try:    
            img = Image.open(input_path)
        
            # Get original dimensions
            orig_width, orig_height = img.size
        
            # Calculate target dimensions
            if dimensions.lower() == "half":
                width = orig_width // 2
                height = orig_height // 2
            elif dimensions.lower() == "96x96":
                width = height = 96
            else:
                try:
                    width_str, height_str = dimensions.lower().split('x')
                    width = int(width_str)
                    height = int(height_str)
                
                    if width <= 0 or height <= 0:
                        raise ValueError("Dimensions must be positive")
                    if width > 10000 or height > 10000:
                        raise ValueError("Dimensions too large")
                except ValueError as e:
                    raise Exception(f"Invalid dimensions format. Use WxH (e.g. 800x600): {str(e)}")

            # Create temp file with unique name
            temp_dir = os.path.dirname(output_path)
            temp_file = os.path.join(temp_dir, f'temp_resize_{os.urandom(8).hex()}.{output_format}')
        
            try:
                # Handle color mode
                if output_format.lower() in ('jpg', 'jpeg') and img.mode == 'RGBA':
                    img = img.convert('RGB')
            
                # High quality resize
                resized_img = img.resize(
                    (width, height),
                    resample=Image.Resampling.LANCZOS,
                    reducing_gap=3.0
                )
            
                # Get format-specific save parameters
                save_kwargs = self._get_save_kwargs(output_path)
            
                # Save resized image
                resized_img.save(temp_file, **save_kwargs)
            
                # Close image handles
                img.close()
                resized_img.close()
            
                # Move to final destination
                if os.path.exists(output_path):
                    os.remove(output_path)
                shutil.move(temp_file, output_path)
            
                return (width, height)
            
            except Exception as e:
                raise Exception(f"Error saving resized image: {str(e)}")
            
        except Exception as e:
            raise Exception(f"Image resize failed: {str(e)}")
        finally:
            # Clean up
            if img:
                try:
                    img.close()
                except:
                    pass
        
            # Clean up temp file if it exists
            if temp_file and os.path.exists(temp_file):
                try:
                    os.remove(temp_file)
                except:
                    pass

class VideoConverter:
    def __init__(self):
        try:
            subprocess.run(['ffmpeg', '-version'], 
                         stdout=subprocess.PIPE, 
                         stderr=subprocess.PIPE)
        except FileNotFoundError:
            raise Exception("FFmpeg not found. Please install FFmpeg to use video conversion.")

    def get_duration(self, input_path):
        """Get video duration in seconds"""
        try:
            cmd = [
                'ffprobe',
                '-v', 'quiet',
                '-show_entries', 'format=duration',
                '-of', 'default=noprint_wrappers=1:nokey=1',
                input_path
            ]
            result = subprocess.run(cmd, capture_output=True, text=True)
            return float(result.stdout.strip())
        except Exception as e:
            raise Exception(f"Failed to get video duration: {str(e)}")

    def resize(self, input_path, output_path, dimensions, maintain_aspect_ratio=True):
        if not os.path.exists(input_path):
            raise Exception(f"Input file not found: {input_path}")

        temp_output = None
        try:
            # Create temp directory if needed
            temp_dir = os.path.dirname(output_path)
            if not os.path.exists(temp_dir):
                os.makedirs(temp_dir)

            # Create unique temp file
            temp_output = os.path.join(temp_dir, f'temp_{os.urandom(8).hex()}.mp4')

            # Parse dimensions
            try:
                width, height = map(int, dimensions.lower().split('x'))
                if width <= 0 or height <= 0:
                    raise ValueError("Dimensions must be positive")
            except ValueError as e:
                raise Exception(f"Invalid dimensions format: {str(e)}")

            # Get total duration for progress calculation
            total_duration = self.get_duration(input_path)

            # Build ffmpeg command
            command = ['ffmpeg', '-i', input_path]

            if maintain_aspect_ratio:
                filter_complex = (
                    f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
                    f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
                )
            else:
                filter_complex = f"scale={width}:{height}"

            command.extend([
                '-vf', filter_complex,
                '-c:v', 'libx264',
                '-preset', 'medium',
                '-crf', '23',
                '-c:a', 'aac',
                '-b:a', '128k',
                '-movflags', '+faststart',
                '-progress', 'pipe:1',  # Output progress to stdout
                '-y',
                temp_output
            ])

            # Run ffmpeg with progress monitoring
            process = subprocess.Popen(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                universal_newlines=True
            )

            # Monitor progress
            time_processed = 0
            while True:
                line = process.stdout.readline()
                if not line and process.poll() is not None:
                    break

                if 'out_time_ms=' in line:
                    time_ms = int(line.split('=')[1]) / 1000000  # Convert microseconds to seconds
                    progress = min(100, (time_ms / total_duration) * 100)
                    print(f"Progress: {progress:.1f}%")  # You can modify this to send progress to frontend

            # Check final status
            if process.returncode != 0:
                raise Exception(f"FFmpeg error: {process.stderr.read()}")

            # Move temp file to final destination
            if os.path.exists(output_path):
                os.remove(output_path)
            shutil.move(temp_output, output_path)

        except Exception as e:
            # Clean up temp file if there was an error
            if temp_output and os.path.exists(temp_output):
                try:
                    os.remove(temp_output)
                except:
                    pass
            raise Exception(f"Video conversion failed: {str(e)}")

        finally:
            # Final cleanup
            if temp_output and os.path.exists(temp_output):
                try:
                    os.remove(temp_output)
                except:
                    pass

4. ./tamplates/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="Online media converter for images and videos">
    <title>Media Converter</title>
    
    <!-- Favicon -->
    <link rel="icon" type="image/jpeg" href="{{ url_for('static', filename='favicon.jpg') }}">
    
    <!-- Stylesheets -->
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css">
    
    <!-- Scripts -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
</head>
<body>
    <div class="container" role="main">
        <h1>Media Converter</h1>
        
        <div class="converter-wrapper">
            <!-- Image Conversion Section -->
            <section class="converter-section" aria-labelledby="image-section-title">
            <h2 id="image-section-title">Image Conversion</h2>
            <form id="imageForm" class="converter-form" role="form">
                <!-- File Input -->
                <div class="form-group">
                    <label for="imageFile">Select Image:</label>
                    <input type="file" 
                           id="imageFile" 
                           name="file" 
                           accept=".jpg,.jpeg,.png,.gif,.bmp,.webp"
                           aria-label="Select image file"
                           required>
                    <small class="help-text">Supported formats: JPG, PNG, GIF, BMP, WebP</small>
                </div>

                <!-- Crop Options -->
                <div class="form-group">
                    <label class="checkbox-label">
                        <input type="checkbox" 
                               id="enableCrop" 
                               name="enable_crop"
                               aria-label="Enable image cropping">
                        Enable Cropping or Image Preview
                    </label>
                </div>
                
                <!-- Cropper Container -->
                <div id="cropperContainer" class="cropper-container hidden" aria-label="Image crop area">
                    <div class="preview-container">
                        <img id="imagePreviewForCrop" alt="Preview image for cropping">
                    </div>
                    <div class="crop-controls">
                        <!-- Aspect Ratio Controls -->
                        <div class="aspect-ratio-controls">
                            <label>
                                <input type="radio" 
                                       name="aspectRatio" 
                                       value="free" 
                                       checked 
                                       aria-label="Free aspect ratio">
                                Free
                            </label>
                            <label>
                                <input type="radio" 
                                       name="aspectRatio" 
                                       value="1:1"
                                       aria-label="Square aspect ratio (1:1)">
                                1:1 Square
                            </label>
                        </div>

                    </div>
                </div>

                <!-- Format Options -->
                <div class="form-group">
                    <label for="imageFormat">Output Format:</label>
                    <select id="imageFormat" 
                            name="output_format"
                            aria-label="Select output format">
                        <option value="">Original format</option>
                        <option value="jpg">JPG</option>
                        <option value="png">PNG</option>
                        <option value="webp">WebP</option>
                    </select>
                </div>
                
                <!-- Resize Options -->
                <div class="form-group">
                    <label>Resize Options:</label>
                    <div class="resize-options">
                        <div>
                            <label>
                                <input type="radio" 
                                       id="originalSize" 
                                       name="resize_option" 
                                       value="original" 
                                       checked
                                       aria-label="Keep original size">
                                Original Size
                            </label>

                            <label>
                                <input type="radio" 
                                       id="halfSize" 
                                       name="resize_option" 
                                       value="half"
                                       aria-label="Resize to half size">
                                Half Size
                            </label>

                            <label>
                                <input type="radio" 
                                       id="96x96" 
                                       name="resize_option" 
                                       value="96x96"
                                       aria-label="Resize to 96 by 96 pixels">
                                96x96
                            </label>

                            <label>
                                <input type="radio" 
                                       id="customSize" 
                                       name="resize_option" 
                                       value="custom"
                                       aria-label="Custom size">
                                Custom Size
                            </label>
                            <input type="text" 
                                   id="imageResize" 
                                   name="resize" 
                                   placeholder="e.g., 800x600, x600, 800*, *600" 
                                   pattern="^\s*(\d+|\*)\s*[xX*]\s*(\d+|\*)\s*$"
                                   aria-label="Custom size dimensions"
                                   disabled>
                            <small class="help-text">Format: WxH, W*, *H (use x/* to maintain ratio)</small>
                        </div>
                    </div>
                </div>
                                <!-- Image Information -->
                                <div id="imageInfo" class="image-info hidden">
                                    <div class="info-section">
                                        <h3>Original Image</h3>
                                        <p>Resolution: <span id="origResolution">-</span></p>
                                        <p>File Size: <span id="origSize">-</span></p>
                                        <p>Format: <span id="origFormat">-</span></p>
                                    </div>
                                    <div class="info-section">
                                        <h3>Converted Image</h3>
                                        <p>Resolution: <span id="convertedResolution">-</span></p>
                                        <p>File Size: <span id="convertedSize">-</span></p>
                                        <p>Format: <span id="convertedFormat">-</span></p>
                                    </div>
                                </div>
                <!-- Submit Button -->
                <button type="submit" 
                        class="submit-btn"
                        aria-label="Convert image">
                    Convert Image
                </button>
            </form>
                    <!-- Status Messages -->
                    <div id="status"
                    class="status hidden"
                    role="alert"
                    aria-live="polite">
                    </div>            
            <!-- Progress Bar -->
            <div id="imageProgress" 
                 class="progress-bar hidden" 
                 role="progressbar" 
                 aria-label="Conversion progress">
            </div>
                    <!-- Image Preview -->
                    <div id="imagePreview" 
                    class="preview-container hidden" 
                    role="region" 
                    aria-label="Converted image preview">
                    </div>
            </section>
        

        
            <!-- Video Conversion Section -->
            <section class="converter-section" aria-labelledby="video-section-title">
                <h2 id="video-section-title">Video Resizing</h2>
                <form id="videoForm" class="converter-form" role="form">
                    <!-- File Input -->
                    <div class="form-group">
                        <label for="videoFile">Select Video:</label>
                        <input type="file" 
                               id="videoFile" 
                               name="file" 
                               accept=".mp4,.avi,.mkv,.mov,.wmv"
                               aria-label="Select video file"
                               required>
                        <small class="help-text">Supported formats: MP4, AVI, MKV, MOV, WMV (Max 100MB)</small>
                    </div>
                    
                    <!-- Video Preview Container -->
                    <div id="videoPreviewContainer" class="preview-container hidden" role="region" aria-label="Video preview">
                        <!-- Preview will be inserted here by JavaScript -->
                    </div>
            
                    <!-- Video Information -->
                    <div id="videoInfo" class="video-info hidden" role="region" aria-label="Video information">
                        <!-- Video metadata will be inserted here by JavaScript -->
                    </div>
                    
                    <!-- Resize Controls -->
                    <div class="form-group">
                        <div class="dimension-controls">
                            <!-- Dimension Presets -->
                            <div class="dimension-presets">
                                <label for="dimensionPresets">Common Dimensions:</label>
                                <select id="dimensionPresets" 
                                        aria-label="Select common dimensions">
                                    <option value="">Custom</option>
                                    <option value="original" disabled>Original Size (Select a video first)</option>
                                    <option value="half" disabled>1/2 Original Size</option>
                                    <option value="quarter" disabled>1/4 Original Size</option>
                                    <option value="1280x720">HD (1280x720)</option>
                                    <option value="1920x1080">Full HD (1920x1080)</option>
                                    <option value="854x480">SD (854x480)</option>
                                    <option value="3840x2160">4K (3840x2160)</option>
                                </select>
                            </div>
            
                            <!-- Custom Dimensions Input -->
                            <div class="custom-dimensions">
                                <label for="videoResize">Custom Dimensions:</label>
                                <input type="text" 
                                       id="videoResize" 
                                       name="resize" 
                                       placeholder="e.g., 1920x1080" 
                                       pattern="\d+x\d+"
                                       aria-label="Custom video dimensions"
                                       required>
                                <small class="help-text">Format: WIDTHxHEIGHT (e.g., 1920x1080)</small>
                            </div>
                        </div>
                    </div>
                    
                    <!-- Aspect Ratio Option -->
                    <div class="form-group">
                        <label class="checkbox-label">
                            <input type="checkbox" 
                                   id="maintainAspectRatio"
                                   name="maintain_aspect_ratio" 
                                   checked
                                   aria-label="Maintain aspect ratio">
                            Maintain Aspect Ratio
                        </label>
                        <small class="help-text">Adds black bars if needed to preserve video proportions</small>
                    </div>
                    <!-- Video Information -->
                    <div id="videoInfo" class="video-info hidden" role="region" aria-label="Video information">
                        <div class="info-comparison">
                            <div class="info-section original-info">
                                <h3>Original Video</h3>
                                <div class="info-content">
                                <!-- Original video info will be inserted here -->
                                </div>
                            </div>
                            <div class="info-section converted-info">
                                <h3>Converted Video</h3>
                                <div class="info-content">
                                <!-- Converted video info will be inserted here -->
                                </div>
                            </div>
                        </div>
                        <div class="comparison-summary hidden">
                            <h3>Comparison Summary</h3>
                            <div class="summary-content">
                            <!-- Comparison details will be inserted here -->
                            </div>
                        </div>
                    </div>
                    
                    <!-- Submit Button -->
                    <button type="submit" 
                            class="submit-btn"
                            aria-label="Convert video">
                        Convert Video
                    </button>
                </form>
                
                <!-- Progress Bar -->
                <div id="videoProgress" 
                     class="progress-bar hidden" 
                     role="progressbar" 
                     aria-label="Video conversion progress">
                     <!-- Progress will be inserted here by JavaScript -->
                </div>
            
                <!-- Status Messages -->
                <div id="status" 
                     class="status hidden" 
                     role="alert" 
                     aria-live="polite">
                     <!-- Status messages will be inserted here by JavaScript -->
                </div>
            </section>
        </div>
    </div>
    
    <!-- Main Script -->
    <script src="{{ url_for('static', filename='js/main.js') }}"></script>
    <script src="{{ url_for('static', filename='js/main-video.js') }}"></script>
</body>
</html>

5. ./static/js/main.js

document.addEventListener('DOMContentLoaded', function() {
    // DOM Elements
    const imageForm = document.getElementById('imageForm');
    const imageProgress = document.getElementById('imageProgress');
    const status = document.getElementById('status');
    const resizeOptions = document.querySelectorAll('input[name="resize_option"]');
    const customResizeInput = document.getElementById('imageResize');
    const fileInputs = document.querySelectorAll('#imageForm input[type="file"]');
    const previewContainer = document.getElementById('imagePreview');
    const imageFileInput = document.getElementById('imageFile');
    const formatSelect = document.getElementById('imageFormat');
    const enableCropCheckbox = document.getElementById('enableCrop');

    // Constants
    const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
    const ALLOWED_IMAGE_TYPES = [
        'image/jpeg',
        'image/png',
        'image/gif',
        'image/bmp',
        'image/webp'
    ];

    // Cropper instance
    let cropper = null;

    // Global download function
    window.downloadImage = function(base64Data, filename, type) {
        try {
            const byteCharacters = atob(base64Data);
            const byteNumbers = new Array(byteCharacters.length);
            
            for (let i = 0; i < byteCharacters.length; i++) {
                byteNumbers[i] = byteCharacters.charCodeAt(i);
            }
            
            const byteArray = new Uint8Array(byteNumbers);
            const blob = new Blob([byteArray], { type: type });
            
            const link = document.createElement('a');
            link.href = URL.createObjectURL(blob);
            link.download = filename;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
            URL.revokeObjectURL(link.href);
        } catch (error) {
            showStatus('Download failed: ' + error.message, true);
        }
    };

    // Utility Functions
    function showProgress(element) {
        element.innerHTML = '<div class="progress"></div>';
        element.classList.remove('hidden');
    }

    function hideProgress(element) {
        element.classList.add('hidden');
    }

    function showStatus(message, isError = false) {
        status.textContent = message;
        status.classList.remove('hidden', 'success', 'error');
        status.classList.add(isError ? 'error' : 'success');
        
        setTimeout(() => {
            status.classList.add('hidden');
        }, 5000);
    }

    function validateFileSize(input) {
        const file = input.files[0];
        if (!file) return false;

        if (file.size > MAX_FILE_SIZE) {
            showStatus('File size must be less than 100MB', true);
            input.value = '';
            return false;
        }
        return true;
    }

    function validateFileType(file, allowedTypes) {
        return allowedTypes.includes(file.type);
    }

    function resetForm(form, keepFile = false) {
        const formatSelect = form.querySelector('select[name="output_format"]');
        if (formatSelect) formatSelect.value = '';
        
        const originalSizeRadio = form.querySelector('input[value="original"]');
        if (originalSizeRadio) originalSizeRadio.checked = true;
        
        const customSizeInput = form.querySelector('input[name="resize"]');
        if (customSizeInput) {
            customSizeInput.value = '';
            customSizeInput.disabled = true;
        }

        if (!keepFile) {
            const fileInput = form.querySelector('input[type="file"]');
            if (fileInput) fileInput.value = '';
        }

        if (cropper) {
            cropper.destroy();
            cropper = null;
        }

        const cropperContainer = document.getElementById('cropperContainer');
        if (cropperContainer) {
            cropperContainer.classList.add('hidden');
        }
    }

    // Format file size
    function formatFileSize(bytes) {
        if (bytes === 0) return '0 Bytes';
    
        const k = 1024;
        const sizes = ['Bytes', 'KB', 'MB', 'GB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
    
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }

    // Update original image info
    function updateOriginalImageInfo(file) {
        const imageInfo = document.getElementById('imageInfo');
        const origResolution = document.getElementById('origResolution');
        const origSize = document.getElementById('origSize');
        const origFormat = document.getElementById('origFormat');
    
        origSize.textContent = formatFileSize(file.size);
        const format = file.type.split('/')[1].toUpperCase();
        origFormat.textContent = format;
    
        const img = new Image();
        img.onload = function() {
            origResolution.textContent = this.width + ' × ' + this.height;
            imageInfo.classList.remove('hidden');
        };
        img.src = URL.createObjectURL(file);
    }

    // Cropper Functions
    function initCropper(file, aspectRatio = NaN) {
        if (!file || !validateFileType(file, ALLOWED_IMAGE_TYPES)) {
            showStatus('Invalid image file', true);
            return;
        }
    
        const reader = new FileReader();
        const previewImg = document.getElementById('imagePreviewForCrop');
        const cropperContainer = document.getElementById('cropperContainer');
        
        reader.onload = function(e) {
            previewImg.src = e.target.result;
            cropperContainer.classList.remove('hidden');
            
            if (cropper) {
                cropper.destroy();
            }
            
            cropper = new Cropper(previewImg, {
                aspectRatio: aspectRatio,
                viewMode: 1,
                autoCropArea: 1,
                movable: true,
                rotatable: false,
                scalable: false,
                zoomable: false,
                guides: true,
                cropBoxResizable: true,
                cropBoxMovable: true,
                background: true,
                responsive: true,
                toggleDragModeOnDblclick: false
            });
        };
        
        reader.onerror = function() {
            showStatus('Error loading image for crop', true);
        };
        
        reader.readAsDataURL(file);
    }

    // Parse dimensions for resize
    async function parseDimensions(dimensionStr) {
        const cleaned = dimensionStr.replace(/\s+/g, '').toLowerCase();
        const [width, height] = cleaned.split(/[x*]/);
        
        if (!width || !height) {
            throw new Error('Invalid format. Use WxH, W*, *H');
        }
        
        const fileInput = document.getElementById('imageFile');
        if (!fileInput.files[0]) {
            throw new Error('Please select an image first');
        }
        
        return new Promise((resolve) => {
            const img = new Image();
            img.onload = function() {
                const origWidth = this.width;
                const origHeight = this.height;
                const aspectRatio = origWidth / origHeight;
                
                let finalWidth = width === '*' ? 
                    (height === '*' ? origWidth : Math.round(parseInt(height) * aspectRatio)) :
                    parseInt(width);
                    
                let finalHeight = height === '*' ? 
                    (width === '*' ? origHeight : Math.round(parseInt(width) / aspectRatio)) :
                    parseInt(height);
                    
                if (isNaN(finalWidth) || isNaN(finalHeight) || 
                    finalWidth <= 0 || finalHeight <= 0 || 
                    finalWidth > 10000 || finalHeight > 10000) {
                    throw new Error('Invalid dimensions. Must be between 1 and 10000 pixels');
                }
                
                resolve(`${finalWidth}x${finalHeight}`);
            };
            
            img.onerror = function() {
                resolve(Promise.reject(new Error('Error loading image')));
            };
            
            img.src = URL.createObjectURL(fileInput.files[0]);
        });
    }

    // Form submission handler
    async function handleImageSubmit(event) {
        event.preventDefault();
        
        const form = event.target;
        
        try {
            const formData = new FormData(form);
            const fileInput = form.querySelector('input[type="file"]');
            
            if (!fileInput.files.length) {
                showStatus('Please select a file', true);
                return;
            }
    
            const file = fileInput.files[0];
            
            if (!validateFileType(file, ALLOWED_IMAGE_TYPES)) {
                showStatus('Invalid image file type', true);
                return;
            }
    
            const resizeOption = form.querySelector('input[name="resize_option"]:checked');
            if (resizeOption) {
                switch(resizeOption.value) {
                    case 'half':
                        formData.set('resize', 'half');
                        break;
                    case '96x96':
                        formData.set('resize', '96x96');
                        break;
                    case 'custom':
                        const resizeInput = form.querySelector('input[name="resize"]');
                        if (resizeInput.value) {
                            try {
                                const dimensions = await parseDimensions(resizeInput.value);
                                formData.set('resize', dimensions);
                            } catch (error) {
                                showStatus(error.message, true);
                                return;
                            }
                        }
                        break;
                }
            }
    
            if (enableCropCheckbox?.checked && cropper) {
                try {
                    const cropData = cropper.getData();
                    formData.append('crop_data', JSON.stringify({
                        x: Math.round(cropData.x),
                        y: Math.round(cropData.y),
                        width: Math.round(cropData.width),
                        height: Math.round(cropData.height)
                    }));
                } catch (error) {
                    showStatus('Error processing crop data: ' + error.message, true);
                    return;
                }
            }
    
            showProgress(imageProgress);
            form.classList.add('loading');
    
            const response = await fetch('/convert/image', {
                method: 'POST',
                body: formData
            });
    
            if (!response.ok) {
                const errorData = await response.json();
                throw new Error(errorData.error || 'Image conversion failed');
            }
    
            const data = await response.json();
    
            const convertedResolution = document.getElementById('convertedResolution');
            const convertedSize = document.getElementById('convertedSize');
            const convertedFormat = document.getElementById('convertedFormat');
            const imageInfo = document.getElementById('imageInfo');
            
            const img = new Image();
            img.onload = function() {
                if (convertedResolution) {
                    convertedResolution.textContent = this.width + ' × ' + this.height;
                }
                if (imageInfo) {
                    imageInfo.classList.remove('hidden');
                }
            };
            img.src = 'data:' + data.type + ';base64,' + data.image;
    
            if (convertedSize) {
                const byteCharacters = atob(data.image);
                const byteSize = byteCharacters.length;
                convertedSize.textContent = formatFileSize(byteSize);
            }

            if (convertedFormat) {
                const format = data.type.split('/')[1].toUpperCase();
                convertedFormat.textContent = format;
            }
    
            previewContainer.innerHTML = `
                <div class="preview-container">
                    <img src="data:${data.type};base64,${data.image}" 
                         alt="Converted image">
                    <div class="preview-info">
                        <button onclick="window.downloadImage('${data.image}', '${data.filename}', '${data.type}')" 
                                class="download-btn">
                            Download
                        </button>
                    </div>
                </div>
            `;
            previewContainer.classList.remove('hidden');
    
            showStatus('Image conversion successful!');
    
        } catch (error) {
            showStatus(error.message || 'Conversion failed', true);
            previewContainer.innerHTML = '';
            previewContainer.classList.add('hidden');
        } finally {
            hideProgress(imageProgress);
            form.classList.remove('loading');
        }
    }

    // Event Listeners
    if (resizeOptions && customResizeInput) {
        resizeOptions.forEach(option => {
            option.addEventListener('change', function() {
                customResizeInput.disabled = this.value !== 'custom';
                if (this.value === 'custom') {
                    customResizeInput.focus();
                }
            });
        });
    }

    if (imageForm) {
        imageForm.addEventListener('submit', handleImageSubmit);
    }

    fileInputs.forEach(input => {
        input.addEventListener('change', function(e) {
            if (!validateFileSize(this)) {
                e.preventDefault();
                return;
            }
        });
    });

    if (imageFileInput) {
        imageFileInput.addEventListener('change', function() {
            if (this.files && this.files[0]) {
                updateOriginalImageInfo(this.files[0]);
                
                const fileName = this.files[0].name.toLowerCase();
                const ext = fileName.split('.').pop();
                
                if (ext === 'png') {
                    formatSelect.value = 'jpg';
                } else {
                    formatSelect.value = '';
                }
            }
            
            if (enableCropCheckbox?.checked && this.files?.[0]) {
                const aspectRatio = document.querySelector('input[name="aspectRatio"]:checked');
                const ratio = aspectRatio?.value === '1:1' ? 1 : NaN;
                initCropper(this.files[0], ratio);
            }
        });
    }

    if (enableCropCheckbox) {
        enableCropCheckbox.addEventListener('change', function() {
            const fileInput = document.getElementById('imageFile');
            if (this.checked && fileInput.files?.[0]) {
                const aspectRatio = document.querySelector('input[name="aspectRatio"]:checked');
                const ratio = aspectRatio?.value === '1:1' ? 1 : NaN;
                initCropper(fileInput.files[0], ratio);
            } else {
                const cropperContainer = document.getElementById('cropperContainer');
                cropperContainer?.classList.add('hidden');
                if (cropper) {
                    cropper.destroy();
                    cropper = null;
                }
            }
        });
    }

    const aspectRatioInputs = document.querySelectorAll('input[name="aspectRatio"]');
    if (aspectRatioInputs) {
        aspectRatioInputs.forEach(input => {
            input.addEventListener('change', function() {
                if (!cropper) return;
                const ratio = this.value === '1:1' ? 1 : NaN;
                cropper.setAspectRatio(ratio);
            });
        });
    }

    // Crop control buttons
    const cropConfirmButton = document.getElementById('cropConfirm');
    if (cropConfirmButton) {
        cropConfirmButton.addEventListener('click', function() {
            document.getElementById('cropperContainer')?.classList.add('hidden');
        });
    }
    
    const cropCancelButton = document.getElementById('cropCancel');
    if (cropCancelButton) {
        cropCancelButton.addEventListener('click', function() {
            const cropperContainer = document.getElementById('cropperContainer');
            const enableCropCheckbox = document.getElementById('enableCrop');
            
            cropperContainer?.classList.add('hidden');
            if (enableCropCheckbox) {
                enableCropCheckbox.checked = false;
            }
            
            if (cropper) {
                cropper.destroy();
                cropper = null;
            }
        });
    }
});

6. ./static/js/main-video.js

document.addEventListener('DOMContentLoaded', function() {
    // DOM Elements
    const videoForm = document.getElementById('videoForm');
    const videoInput = document.getElementById('videoFile');
    const videoProgress = document.getElementById('videoProgress');
    const status = document.getElementById('status');
    const dimensionsInput = document.getElementById('videoResize');
    const formatSelect = document.getElementById('videoFormat');
    const qualitySelect = document.getElementById('videoQuality');
    const aspectRatioCheckbox = document.querySelector('input[name="maintain_aspect_ratio"]');

    // Validate required elements exist
    if (!videoForm || !videoInput || !videoProgress || !status || 
        !dimensionsInput || !formatSelect || !qualitySelect) {
        console.error('Required DOM elements not found');
        return;
    }

    // Find the video converter section
    const videoSection = videoForm.closest('.converter-section');

    // Create and insert preview container
    const previewContainer = document.createElement('div');
    previewContainer.id = 'videoPreviewContainer';
    previewContainer.className = 'preview-container hidden';
    videoForm.after(previewContainer);

    // Create and insert video info container
    const videoInfo = document.createElement('div');
    videoInfo.id = 'videoInfo';
    videoInfo.className = 'video-info hidden';
    previewContainer.after(videoInfo);

    // Setup video info structure
    videoInfo.innerHTML = `
        <div class="info-comparison">
            <div class="info-section original-info">
                <h3>Original Video</h3>
                <div class="info-content">
                    <!-- Original video info will be inserted here -->
                </div>
            </div>
            <div class="info-section converted-info">
                <h3>Converted Video</h3>
                <div class="info-content">
                    <!-- Converted video info will be inserted here -->
                </div>
            </div>
        </div>
        <div class="comparison-summary hidden">
            <h3>Comparison Summary</h3>
            <div class="summary-content">
                <!-- Comparison details will be inserted here -->
            </div>
        </div>
    `;

    // Constants
    const MAX_FILE_SIZE = 7 * 1024 * 1024 * 1024; // 7GB
    const ALLOWED_VIDEO_TYPES = [
        'video/mp4',
        'video/x-msvideo',
        'video/x-matroska',
        'video/quicktime',
        'video/x-ms-wmv',
        'video/webm',
        'video/ogg',
        'video/mpeg'
    ];

    const FORMAT_INFO = {
        'mp4': {
            mime: 'video/mp4',
            codec: 'H.264',
            extension: 'mp4',
            type: 'video'
        },
        'webm': {
            mime: 'video/webm',
            codec: 'VP8',
            extension: 'webm',
            type: 'video'
        },
        'webm-vp9': {
            mime: 'video/webm',
            codec: 'VP9',
            extension: 'webm',
            type: 'video'
        },
        'weba': {
            mime: 'audio/webm',
            codec: 'Opus',
            extension: 'weba',
            type: 'audio'
        },
        'avi': {
            mime: 'video/x-msvideo',
            codec: 'MPEG4',
            extension: 'avi',
            type: 'video'
        }
    };

    // Track original video dimensions and info
    let originalDimensions = {
        width: 0,
        height: 0
    };
    let originalVideoInfo = null;

    // Utility Functions
    function formatFileSize(bytes) {
        if (bytes === 0) return '0 Bytes';
        const k = 1024;
        const sizes = ['Bytes', 'KB', 'MB', 'GB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }

    function formatDuration(seconds) {
        const h = Math.floor(seconds / 3600);
        const m = Math.floor((seconds % 3600) / 60);
        const s = Math.floor(seconds % 60);
        return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
    }

    function formatBitrate(bitsPerSecond) {
        return formatFileSize(bitsPerSecond / 8) + '/s';
    }

    function parseProgress(text) {
        try {
            const progressMatch = text.match(/Progress:\s*([\d.]+)/);
            if (progressMatch) {
                return parseFloat(progressMatch[1]);
            }
        } catch (e) {
            console.error('Error parsing progress:', e);
        }
        return null;
    }

    function showProgress(element) {
        element.innerHTML = '<div class="progress"></div>';
        element.classList.remove('hidden');
    }

    function updateProgress(element, percent) {
        const progress = element.querySelector('.progress');
        if (progress) {
            progress.style.width = `${percent}%`;
            progress.textContent = `${Math.round(percent)}%`;
        }
    }

    function hideProgress(element) {
        element.classList.add('hidden');
    }

    function showStatus(message, isError = false) {
        status.textContent = message;
        status.classList.remove('hidden', 'success', 'error');
        status.classList.add(isError ? 'error' : 'success');
        
        if (!isError) {
            setTimeout(() => {
                status.classList.add('hidden');
            }, 5000);
        }
    }

    // Format-related functions
    function getFormatInfo(format, originalFormat = '') {
        if (!format) {
            return {
                mime: originalFormat,
                codec: 'Original',
                extension: originalFormat.split('/')[1],
                type: originalFormat.startsWith('video/') ? 'video' : 'audio'
            };
        }
        return FORMAT_INFO[format];
    }

    // Add dimension presets to form
    function addDimensionPresets() {
        const presetContainer = document.createElement('div');
        presetContainer.className = 'dimension-presets';
        
        const presetLabel = document.createElement('label');
        presetLabel.textContent = 'Common Dimensions:';
        presetContainer.appendChild(presetLabel);

        const presetSelect = document.createElement('select');
        presetSelect.id = 'dimensionPresets';
        presetSelect.innerHTML = `
            <option value="">Custom</option>
            <option value="original" disabled>Original Size (Select a video first)</option>
            <option value="half" disabled>1/2 Original Size</option>
            <option value="quarter" disabled>1/4 Original Size</option>
            <option value="1280x720">HD (1280x720)</option>
            <option value="1920x1080">Full HD (1920x1080)</option>
            <option value="854x480">SD (854x480)</option>
            <option value="3840x2160">4K (3840x2160)</option>
        `;

        presetSelect.addEventListener('change', (e) => {
            const selectedValue = e.target.value;
            if (!selectedValue) return;

            let newDimensions = '';
            if (selectedValue === 'original') {
                newDimensions = `${originalDimensions.width}x${originalDimensions.height}`;
            } else if (selectedValue === 'half') {
                const halfWidth = Math.round(originalDimensions.width / 2);
                const halfHeight = Math.round(originalDimensions.height / 2);
                newDimensions = `${halfWidth}x${halfHeight}`;
            } else if (selectedValue === 'quarter') {
                const quarterWidth = Math.round(originalDimensions.width / 4);
                const quarterHeight = Math.round(originalDimensions.height / 4);
                newDimensions = `${quarterWidth}x${quarterHeight}`;
            } else {
                newDimensions = selectedValue;
            }
            
            dimensionsInput.value = newDimensions;
        });

        presetContainer.appendChild(presetSelect);
        dimensionsInput.parentNode.insertBefore(presetContainer, dimensionsInput);
    }

    // Update dimension presets based on video
    function updateDimensionPresets() {
        const presetSelect = document.getElementById('dimensionPresets');
        if (!presetSelect) return;

        const options = presetSelect.options;
        // Enable/update dynamic options
        for (let i = 0; i < options.length; i++) {
            const option = options[i];
            if (option.value === 'original') {
                option.disabled = !originalDimensions.width;
                option.textContent = originalDimensions.width ? 
                    `Original Size (${originalDimensions.width}x${originalDimensions.height})` :
                    'Original Size (Select a video first)';
            } else if (option.value === 'half') {
                option.disabled = !originalDimensions.width;
                const halfWidth = Math.round(originalDimensions.width / 2);
                const halfHeight = Math.round(originalDimensions.height / 2);
                option.textContent = originalDimensions.width ? 
                    `1/2 Original Size (${halfWidth}x${halfHeight})` :
                    '1/2 Original Size';
            } else if (option.value === 'quarter') {
                option.disabled = !originalDimensions.width;
                const quarterWidth = Math.round(originalDimensions.width / 4);
                const quarterHeight = Math.round(originalDimensions.height / 4);
                option.textContent = originalDimensions.width ? 
                    `1/4 Original Size (${quarterWidth}x${quarterHeight})` :
                    '1/4 Original Size';
            }
        }
    }

    // Video Preview and Info Display
    function formatVideoInfo(info) {
        const formatDetails = info.formatDetails || { codec: 'Unknown' };
        return `
            <p><strong>Dimensions:</strong> ${info.width}x${info.height}</p>
            <p><strong>Duration:</strong> ${info.duration}</p>
            <p><strong>File Size:</strong> ${info.fileSize}</p>
            <p><strong>Format:</strong> ${info.format} (${formatDetails.codec})</p>
            ${info.fps ? `<p><strong>FPS:</strong> ${info.fps}</p>` : ''}
            ${info.bitrate ? `<p><strong>Bitrate:</strong> ${formatBitrate(info.bitrate)}</p>` : ''}
        `;
    }

    function updateComparisonSummary(original, converted) {
        const originalSize = original.fileSizeBytes;
        const convertedSize = converted.fileSizeBytes;
        const compressionRatio = ((originalSize - convertedSize) / originalSize * 100).toFixed(1);
        
        const summarySection = videoInfo.querySelector('.comparison-summary');
        const summaryContent = summarySection.querySelector('.summary-content');
        
        let fpsComparison = '';
        if (original.fps && converted.fps) {
            const fpsDiff = converted.fps - original.fps;
            const fpsChange = fpsDiff > 0 ? `+${fpsDiff}` : fpsDiff;
            fpsComparison = `<p><strong>FPS Change:</strong> ${original.fps} → ${converted.fps} (${fpsChange} FPS)</p>`;
        }

        let formatComparison = '';
        if (original.formatDetails?.codec !== converted.formatDetails?.codec) {
            formatComparison = `
                <p><strong>Format Change:</strong> 
                   ${original.format} (${original.formatDetails.codec}) → 
                   ${converted.format} (${converted.formatDetails.codec})
                </p>
            `;
        }
        
        summaryContent.innerHTML = `
            <p><strong>Size Reduction:</strong> ${compressionRatio}%</p>
            <p><strong>Space Saved:</strong> ${formatFileSize(originalSize - convertedSize)}</p>
            <p><strong>Resolution Change:</strong> ${original.width}x${original.height} → ${converted.width}x${converted.height}</p>
            ${formatComparison}
            ${fpsComparison}
        `;
        
        summarySection.classList.remove('hidden');
    }

    // FPS Detection Function
    async function detectFPS(video) {
        return new Promise((resolve) => {
            let frameCount = 0;
            let lastTime = 0;
            let fps = 0;
            
            if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
                let frames = 0;
                let lastTs = 0;
                
                const frameCallback = (now, metadata) => {
                    if (lastTs) {
                        const elapsed = (now - lastTs) / 1000;
                        if (elapsed >= 1) {
                            fps = Math.round(frames / elapsed);
                            resolve(fps);
                            return;
                        }
                        frames++;
                    }
                    lastTs = now;
                    video.requestVideoFrameCallback(frameCallback);
                };
                
                video.requestVideoFrameCallback(frameCallback);
                
            } else {
                const detectFrames = (timestamp) => {
                    if (lastTime) {
                        frameCount++;
                        const elapsed = timestamp - lastTime;
                        
                        if (elapsed >= 1000) {
                            fps = Math.round((frameCount * 1000) / elapsed);
                            resolve(fps);
                            return;
                        }
                    }
                    lastTime = timestamp;
                    requestAnimationFrame(detectFrames);
                };
                
                requestAnimationFrame(detectFrames);
            }
            
            setTimeout(() => {
                if (fps === 0) {
                    if (video.webkitDecodedFrameCount) {
                        fps = Math.round(video.webkitDecodedFrameCount / video.currentTime);
                    } else if (video.mozDecodedFrames) {
                        fps = Math.round(video.mozDecodedFrames / video.currentTime);
                    } else {
                        fps = null;
                    }
                    resolve(fps);
                }
            }, 1000);
            
            video.play().catch(() => {
                resolve(null);
            });
        });
    }

    function showVideoPreview(file) {
        const video = document.createElement('video');
        video.controls = true;
        video.style.maxWidth = '100%';
        video.style.maxHeight = '400px';

        const source = document.createElement('source');
        source.src = URL.createObjectURL(file);
        source.type = file.type;

        video.appendChild(source);
        previewContainer.innerHTML = '';
        previewContainer.appendChild(video);
        previewContainer.classList.remove('hidden');

        // Cleanup when video is removed
        video.addEventListener('emptied', () => {
            if (videoUrl) {
                URL.revokeObjectURL(videoUrl);
            }
        });

        video.onloadedmetadata = async function() {
            originalDimensions = {
                width: video.videoWidth,
                height: video.videoHeight
            };

            const fps = await detectFPS(video);

            originalVideoInfo = {
                width: video.videoWidth,
                height: video.videoHeight,
                duration: formatDuration(video.duration),
                fileSize: formatFileSize(file.size),
                fileSizeBytes: file.size,
                format: file.type.split('/')[1].toUpperCase(),
                formatDetails: getFormatInfo('', file.type),
                fps: fps,
                bitrate: Math.round(file.size * 8 / video.duration)
            };

            // Update original info section
            const originalInfoSection = videoInfo.querySelector('.original-info .info-content');
            originalInfoSection.innerHTML = formatVideoInfo(originalVideoInfo);
            
            // Clear converted info
            const convertedInfoSection = videoInfo.querySelector('.converted-info .info-content');
            convertedInfoSection.innerHTML = '<p>Not converted yet</p>';
            
            videoInfo.classList.remove('hidden');
            videoInfo.querySelector('.comparison-summary').classList.add('hidden');

            updateDimensionPresets();

            if (!dimensionsInput.value) {
                dimensionsInput.value = `${originalDimensions.width}x${originalDimensions.height}`;
            }

            updateUIForFormat(file.type);
        };

        video.onerror = function() {
            showStatus('Error loading video preview', true);
            previewContainer.classList.add('hidden');
        };
    }
    
    // Update UI based on selected format
    function updateUIForFormat(inputFormat) {
        const selectedFormat = formatSelect.value;
        const formatInfo = getFormatInfo(selectedFormat, inputFormat);
        
        if (formatInfo.type === 'audio') {
            qualitySelect.value = 'medium';
            dimensionsInput.parentElement.style.display = 'none';
            aspectRatioCheckbox.parentElement.style.display = 'none';
        } else {
            dimensionsInput.parentElement.style.display = '';
            aspectRatioCheckbox.parentElement.style.display = '';
        }

        if (selectedFormat === 'webm' || selectedFormat === 'webm-vp9') {
            qualitySelect.querySelector('option[value="high"]').textContent = 'High (CRF 20)';
            qualitySelect.querySelector('option[value="medium"]').textContent = 'Medium (CRF 30)';
            qualitySelect.querySelector('option[value="low"]').textContent = 'Low (CRF 40)';
        } else {
            qualitySelect.querySelector('option[value="high"]').textContent = 'High (Larger file)';
            qualitySelect.querySelector('option[value="medium"]').textContent = 'Medium (Balanced)';
            qualitySelect.querySelector('option[value="low"]').textContent = 'Low (Smaller file)';
        }
    }
    // Validation Functions
    function validateFileType(file) {
        // Check mime type
        if (!ALLOWED_VIDEO_TYPES.includes(file.type)) {
            showStatus('Invalid video file type. Supported formats: MP4, AVI, MKV, MOV, WMV, WebM, OGG, MPEG', true);
            return false;
        }
        
        // Check file extension as backup validation
        const extension = file.name.split('.').pop().toLowerCase();
        const validExtensions = ['mp4', 'avi', 'mkv', 'mov', 'wmv', 'webm', 'ogv', 'mpeg', 'mpg'];
        if (!validExtensions.includes(extension)) {
            showStatus('Invalid file extension. Supported extensions: ' + validExtensions.join(', '), true);
            return false;
        }
        
        return true;
    }

    function validateFileSize(file) {
        if (file.size > MAX_FILE_SIZE) {
            showStatus('File size must be less than 7GB', true);
            return false;
        }
        return true;
    }

    function validateDimensions(dimensions) {
        if (!dimensions) return true;  // Allow empty for original size

        const [width, height] = dimensions.split('x').map(Number);
        if (!width || !height || width <= 0 || height <= 0 || width > 7680 || height > 4320) {
            showStatus('Invalid dimensions. Max resolution is 7680x4320', true);
            return false;
        }
        return true;
    }
    // Form Submission Handler
    async function handleVideoSubmit(event) {
        event.preventDefault();
        
        const form = event.target;
        const formData = new FormData(form);
        const fileInput = form.querySelector('input[type="file"]');
        const file = fileInput.files[0];

        if (!file) {
            showStatus('Please select a file', true);
            return;
        }

        if (!validateFileType(file) || !validateFileSize(file)) {
            return;
        }

        const selectedFormat = formatSelect.value;
        const isAudioOnly = selectedFormat === 'weba' || 
                         getFormatInfo(selectedFormat, file.type).type === 'audio';

        if (!isAudioOnly && !validateDimensions(dimensionsInput.value)) {
            return;
        }

        showProgress(videoProgress);
        form.classList.add('loading');
        showStatus('Converting video... Please wait');

        try {
            const response = await fetch('/convert/video', {
                method: 'POST',
                body: formData
            });

            if (!response.ok) {
                let errorMessage;
                try {
                    const errorData = await response.json();
                    errorMessage = errorData.error || 'Video conversion failed';
                } catch {
                    errorMessage = await response.text() || 'Video conversion failed';
                }
                throw new Error(errorMessage);
            }

            // Check if response is progress stream or completed file
            const contentType = response.headers.get('Content-Type');
            if (contentType && contentType.includes('text/event-stream')) {
                // Handle progress updates
                const reader = response.body.getReader();
                
                while(true) {
                    const {done, value} = await reader.read();
                    if (done) break;
                    
                    const text = new TextDecoder().decode(value);
                    const lines = text.split('\n');
                    
                    for (const line of lines) {
                        if (line.includes('Progress:')) {
                            const progress = parseFloat(line.split(':')[1]);
                            updateProgress(videoProgress, progress);
                        }
                    }
                }
                
                // Get download URL after processing completes
                const downloadResponse = await fetch('/download/latest');
                const blob = await downloadResponse.blob();
                const url = URL.createObjectURL(blob);
                
                try {
                    const a = document.createElement('a');
                    a.href = url;
                    a.download = response.headers.get('filename') || 'converted_video';
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);
                } finally {
                    URL.revokeObjectURL(url);
                }
                
            } else {
                // Direct file download
                const blob = await response.blob();
                const url = URL.createObjectURL(blob);
                
                try {
                    const a = document.createElement('a');
                    a.href = url;
                    a.download = response.headers.get('filename') || 'converted_video';
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);
                } finally {
                    URL.revokeObjectURL(url);
                }
            }

            showStatus('Video conversion completed successfully!');

        } catch (error) {
            console.error('Conversion error:', error);
            showStatus(error.message || 'Video conversion failed', true);
        } finally {
            hideProgress(videoProgress);
            form.classList.remove('loading');
        }
    }
    // Event Listeners
    if (videoForm) {
        videoForm.addEventListener('submit', handleVideoSubmit);
    }

    if (videoInput) {
        videoInput.addEventListener('change', function(e) {
            const file = this.files[0];
            if (file) {
                if (validateFileType(file) && validateFileSize(file)) {
                    showVideoPreview(file);
                } else {
                    this.value = '';
                    previewContainer.classList.add('hidden');
                    videoInfo.classList.add('hidden');
                }
            }
        });
    }

    formatSelect.addEventListener('change', function(e) {
        const inputFormat = originalVideoInfo ? originalVideoInfo.formatDetails.mime : '';
        updateUIForFormat(inputFormat);
    });

        // Initialize dimension presets
        addDimensionPresets();
});

7. ./static/css/style.css

/* Reset and Base Styles */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

/* Typography and Base Styles */
body {
    font-family: Arial, sans-serif;
    line-height: 1.6;
    background-color: #f5f5f5;
    color: #333;
    min-height: 100vh;
}

/* Layout */
.container {
    max-width: 1200px;
    margin: 2rem auto;
    padding: 0 1rem;
}

h1 {
    text-align: center;
    color: #2c3e50;
    margin-bottom: 2rem;
    font-size: 2.5rem;
}

h2 {
    color: #34495e;
    margin-bottom: 1.5rem;
    font-size: 1.8rem;
}

/* Sections */
.converter-section {
    background: white;
    border-radius: 8px;
    padding: 1.5rem;
    margin-bottom: 2rem;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

/* Form Elements */
.converter-form {
    display: flex;
    flex-direction: column;
    gap: 1.5rem;
}

.form-group {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

/* Labels and Inputs */
label {
    font-weight: 600;
    color: #2c3e50;
    font-size: 1rem;
}

.help-text {
    color: #666;
    font-size: 0.9rem;
    margin-top: 0.25rem;
}

input[type="file"],
input[type="text"],
select {
    padding: 0.75rem;
    border: 2px solid #ddd;
    border-radius: 6px;
    font-size: 1rem;
    transition: border-color 0.3s, box-shadow 0.3s;
}

input[type="text"],
select {
    width: 100%;
    max-width: 300px;
}

input[type="file"] {
    background: #f8f9fa;
    cursor: pointer;
}

input[type="text"]:focus,
select:focus {
    border-color: #3498db;
    outline: none;
    box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}

/* Checkbox and Radio Styles */
.checkbox-label {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    cursor: pointer;
    user-select: none;
}

input[type="checkbox"],
input[type="radio"] {
    width: 16px;
    height: 16px;
    cursor: pointer;
}

/* Buttons */
button {
    background-color: #3498db;
    color: white;
    padding: 0.75rem 1.5rem;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-size: 1rem;
    font-weight: 600;
    transition: all 0.3s;
}

button:hover {
    background-color: #2980b9;
    transform: translateY(-1px);
}

button:active {
    transform: translateY(0);
}

.download-btn {
    background-color: #27ae60;
    margin-top: 1rem;
}

.download-btn:hover {
    background-color: #219a52;
}

/* Resize Options */
.resize-options {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}

.resize-options div {
    display: flex;
    align-items: center;
    gap: 1rem;
    flex-wrap: wrap;
}

.resize-options input[type="text"] {
    width: 150px;
}

.resize-options input[type="text"]:disabled {
    background-color: #f5f5f5;
    cursor: not-allowed;
    opacity: 0.7;
}

/* Cropper Interface */
.cropper-container {
    margin: 1rem 0;
    max-width: 100%;
    min-height: 300px;
    max-height: 80vh;
    height: auto;
    background: #f8f9fa;
    border-radius: 6px;
    overflow: hidden;
    position: relative;
    display: flex;
    flex-direction: column;
}

.cropper-container img {
    max-width: 100%;
    max-height: 100%;
}

/* Aspect Ratio Controls */
.aspect-ratio-controls {
    display: flex;
    gap: 1.5rem;
    padding: 1rem;
    background: #f8f9fa;
    border-top: 1px solid #eee;
    position: relative;
    bottom: 0;
    width: 100%;
    z-index: 100;
}

.aspect-ratio-controls label {
    display: flex !important;
    align-items: center;
    gap: 0.5rem;
    cursor: pointer;
    color: #000 !important;
    font-weight: 500 !important;
    padding: 0.5rem 1rem;
    background: white;
    border-radius: 4px;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
    opacity: 1 !important;
    visibility: visible !important;
    user-select: none;
}

/* Preview Containers */
.preview-container {
    margin: 1rem auto;
    text-align: center;
    max-width: 800px;
}

/* Cropper Preview */
.cropper-container .preview-container {
    flex: 1;
    position: relative;
    min-height: 300px;
    max-height: calc(80vh - 60px);
    width: 100%;
}

.cropper-container .preview-container img {
    max-width: 100%;
    max-height: 100%;
    width: auto;
    height: auto;
}

/* Output Preview */
#imagePreview .preview-container {
    max-height: calc(100vh - 200px);
    overflow: hidden;
}

#imagePreview .preview-container img {
    max-width: 100%;
    max-height: calc(100vh - 250px);
    margin: 0 auto 1rem;
    border-radius: 4px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    object-fit: contain;
}

/* Progress Bar */
.progress-bar {
    background-color: #ecf0f1;
    border-radius: 6px;
    height: 20px;
    margin-top: 1rem;
    overflow: hidden;
    box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
}

.progress-bar .progress {
    background-color: #2ecc71;
    height: 100%;
    width: 0;
    transition: width 0.3s ease;
    border-radius: 6px;
}

/* Status Messages */
.status {
    padding: 1rem;
    border-radius: 6px;
    margin-top: 1rem;
    font-weight: 500;
}

.status.success {
    background-color: #d4edda;
    color: #155724;
    border: 1px solid #c3e6cb;
}

.status.error {
    background-color: #f8d7da;
    color: #721c24;
    border: 1px solid #f5c6cb;
}

/* Loading State */
.loading {
    opacity: 0.7;
    pointer-events: none;
    position: relative;
}

.loading::after {
    content: '';
    position: absolute;
    top: calc(50% - 1rem);
    left: calc(50% - 1rem);
    width: 2rem;
    height: 2rem;
    border: 3px solid #f3f3f3;
    border-top: 3px solid #3498db;
    border-radius: 50%;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

/* Cropper Plugin Customization */
.cropper-view-box {
    outline-color: #3498db !important;
}

.cropper-line {
    background-color: #3498db !important;
}

.cropper-point {
    background-color: #3498db !important;
}

.cropper-face {
    background-color: #3498db !important;
}

/* Utility Classes */
.hidden {
    display: none !important;
}

/* Focus Styles */
*:focus {
    outline: none;
    box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.4);
}

/* Responsive Design */
@media (max-width: 600px) {
    .container {
        margin: 1rem auto;
        padding: 0 0.75rem;
    }
    
    .converter-section {
        padding: 1rem;
    }
    
    h1 {
        font-size: 2rem;
    }
    
    h2 {
        font-size: 1.5rem;
    }
    
    button {
        width: 100%;
    }
    
    .aspect-ratio-controls {
        flex-direction: column;
        gap: 0.75rem;
    }
    
    .resize-options div {
        flex-direction: column;
        align-items: flex-start;
    }
    
    input[type="text"],
    select {
        max-width: 100%;
    }
    
    .cropper-container {
        height: 300px;
    }
    
    #imagePreview .preview-container img {
        max-height: calc(100vh - 300px);
    }
}
/* Image Info Styles */
.image-info {
    display: flex;
    gap: 2rem;
    margin: 1rem 0;
    padding: 1rem;
    background: #f8f9fa;
    border-radius: 6px;
    border: 1px solid #e9ecef;
}

.info-section {
    flex: 1;
}

.info-section h3 {
    color: #2c3e50;
    font-size: 1.1rem;
    margin-bottom: 0.5rem;
}

.info-section p {
    margin: 0.25rem 0;
    color: #495057;
}

@media (max-width: 600px) {
    .image-info {
        flex-direction: column;
        gap: 1rem;
    }
}
/* Add side by side layout */
.converter-wrapper {
    display: flex;
    gap: 2rem;
    align-items: flex-start;
}

.converter-section {
    flex: 1;
    min-width: 0; /* Prevent flex items from overflowing */
}

/* Responsive design for mobile */
@media (max-width: 900px) {
    .converter-wrapper {
        flex-direction: column;
        gap: 1rem;
    }
    
    .converter-section {
        width: 100%;
    }
}

/* Video Preview Styles */
#videoPreviewContainer {
    margin: 1.5rem 0;
    background: #f8f9fa;
    border-radius: 8px;
    padding: 1rem;
    text-align: center;
}

#videoPreviewContainer video {
    max-width: 100%;
    max-height: 400px;
    border-radius: 4px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

/* Video Information Styles */
.video-info {
    background: #f8f9fa;
    border: 1px solid #e9ecef;
    border-radius: 8px;
    padding: 1rem;
    margin: 1rem 0;
}

.video-info .info-section {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 1rem;
}

.video-info h3 {
    color: #2c3e50;
    font-size: 1.1rem;
    margin-bottom: 0.75rem;
    grid-column: 1 / -1;
}

.video-info p {
    margin: 0.25rem 0;
    color: #495057;
}

/* Dimension Controls */
.dimension-controls {
    display: grid;
    gap: 1rem;
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}

.dimension-presets,
.custom-dimensions {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

#dimensionPresets {
    padding: 0.75rem;
    border: 2px solid #ddd;
    border-radius: 6px;
    font-size: 1rem;
    background-color: white;
    cursor: pointer;
    transition: border-color 0.3s, box-shadow 0.3s;
}

#dimensionPresets:focus {
    border-color: #3498db;
    box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}

/* Loading State Enhancement */
.loading::after {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(255, 255, 255, 0.8);
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 8px;
}

/* Progress Bar Enhancement */
.progress-bar .progress {
    position: relative;
    overflow: hidden;
}

.progress-bar .progress::after {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    background: linear-gradient(
        90deg,
        transparent,
        rgba(255, 255, 255, 0.2),
        transparent
    );
    animation: progress-animation 1.5s linear infinite;
}

@keyframes progress-animation {
    0% { transform: translateX(-100%); }
    100% { transform: translateX(100%); }
}

/* Responsive Adjustments */
@media (max-width: 600px) {
    .dimension-controls {
        grid-template-columns: 1fr;
    }
    
    .video-info .info-section {
        grid-template-columns: 1fr;
    }
    
    #videoPreviewContainer video {
        max-height: 300px;
    }
}
/* Video Info Comparison Styles */
.video-info {
    background: #f8f9fa;
    border: 1px solid #e9ecef;
    border-radius: 8px;
    padding: 1.5rem;
    margin: 1.5rem 0;
}

.info-comparison {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 2rem;
    margin-bottom: 1.5rem;
}

.info-section {
    background: white;
    border-radius: 6px;
    padding: 1.25rem;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.info-section h3 {
    color: #2c3e50;
    font-size: 1.1rem;
    margin-bottom: 1rem;
    padding-bottom: 0.5rem;
    border-bottom: 2px solid #e9ecef;
}

.info-content p {
    margin: 0.5rem 0;
    color: #495057;
    line-height: 1.5;
}

.info-content strong {
    color: #2c3e50;
    font-weight: 600;
    min-width: 100px;
    display: inline-block;
}

.comparison-summary {
    background: #e8f4f8;
    border-radius: 6px;
    padding: 1.25rem;
    margin-top: 1rem;
}

.comparison-summary h3 {
    color: #2c3e50;
    font-size: 1.1rem;
    margin-bottom: 1rem;
}

.summary-content p {
    margin: 0.5rem 0;
    color: #2c3e50;
    font-weight: 500;
}

.converted-info {
    position: relative;
}

.converted-info::before {
    content: '→';
    position: absolute;
    left: -1.5rem;
    top: 50%;
    transform: translateY(-50%);
    font-size: 1.5rem;
    color: #3498db;
}

/* Responsive Adjustments */
@media (max-width: 768px) {
    .info-comparison {
        grid-template-columns: 1fr;
        gap: 1rem;
    }

    .converted-info::before {
        content: '↓';
        left: 50%;
        top: -1.5rem;
        transform: translateX(-50%);
    }
}

/* Loading State */
.info-section.loading {
    position: relative;
}

.info-section.loading::after {
    content: '';
    position: absolute;
    inset: 0;
    background: rgba(255, 255, 255, 0.8);
    border-radius: 6px;
}

/* Format and Quality Selection Styles */
.format-controls {
    display: grid;
    gap: 1rem;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}

#videoFormat,
#videoQuality {
    width: 100%;
    padding: 0.75rem;
    border: 2px solid #ddd;
    border-radius: 6px;
    font-size: 1rem;
    background-color: white;
    cursor: pointer;
    transition: all 0.3s ease;
}

#videoFormat:hover,
#videoQuality:hover {
    border-color: #3498db;
}

#videoFormat:focus,
#videoQuality:focus {
    border-color: #3498db;
    box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
    outline: none;
}

.format-info {
    margin-top: 0.5rem;
    font-size: 0.9rem;
    color: #666;
}

/* Format-specific indicators */
.format-badge {
    display: inline-block;
    padding: 0.25rem 0.5rem;
    border-radius: 4px;
    font-size: 0.8rem;
    font-weight: 500;
    margin-left: 0.5rem;
}

.format-badge.webm {
    background-color: #2ecc71;
    color: white;
}

.format-badge.audio {
    background-color: #9b59b6;
    color: white;
}

/* Quality indicator */
.quality-indicator {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-top: 0.5rem;
}

.quality-bar {
    height: 4px;
    border-radius: 2px;
    flex-grow: 1;
}

.quality-high .quality-bar {
    background-color: #2ecc71;
}

.quality-medium .quality-bar {
    background-color: #f1c40f;
}

.quality-low .quality-bar {
    background-color: #e74c3c;
}

/* Responsive adjustments */
@media (max-width: 600px) {
    .format-controls {
        grid-template-columns: 1fr;
    }
}

Docker 部署

1. Dockerfile

# Use Python slim image as base
FROM python:3.12-slim

# Set working directory
WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    ffmpeg \
    libpq-dev \
    gcc \
    libjpeg-dev \
    zlib1g-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY app.py converter.py ./
COPY static/ ./static/
COPY templates/ ./templates/
COPY cert.pem .
COPY key.pem .

# Create temp directory with proper permissions
RUN mkdir -p /app/temp && \
    chmod 777 /app/temp

# Create non-root user
RUN adduser --disabled-password --gecos '' appuser
USER appuser

# Expose port
EXPOSE 9002

# Set environment variables
ENV FLASK_APP=app.py
ENV PYTHONUNBUFFERED=1
ENV FLASK_ENV=production

# Run the application
CMD ["python", "app.py"]

2. requirements.txt

blinker==1.9.0
click==8.1.8
colorama==0.4.6
ffmpeg-python==0.2.0
Flask==3.1.0
future==1.0.0
itsdangerous==2.2.0
Jinja2==3.1.5
MarkupSafe==3.0.2
pillow==11.0.0
Werkzeug==3.1.3

3. 在 NAS 上部署

因为之前有同名的 image 与 container,用 docker + stop, rm, rmi ID 都以删除

CMD: docker build
[/share/Multimedia/2024-MyProgramFiles/4.mp4jpg] # docker build -t 2mp4jpg
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            Install the buildx component to build images with BuildKit:
            https://docs.docker.com/go/buildx/

"docker build" requires exactly 1 argument.
See 'docker build --help'.

Usage:  docker build [OPTIONS] PATH | URL | -

Build an image from a Dockerfile
[/share/Multimedia/2024-MyProgramFiles/4.mp4jpg] # docker build -t 2mp4jpg .
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
            Install the buildx component to build images with BuildKit:
            https://docs.docker.com/go/buildx/

Sending build context to Docker daemon  29.46MB
Step 1/16 : FROM python:3.12-slim
....

Step 16/16 : CMD ["python", "app.py"]
 ---> Running in 02573b32538b
 ---> Removed intermediate container 02573b32538b
 ---> 722bf92ce3fd
Successfully built 722bf92ce3fd
Successfully tagged 2mp4jpg:latest
[/share/Multimedia/2024-MyProgramFiles/4.mp4jpg] #
CMD:docker run
[/share/Multimedia/2024-MyProgramFiles/4.mp4jpg] # docker run -d -p 9002:9002 --name 2mp4jpg_container --restart always 2mp4jpg
de8306c09d9ce90327c99ca12ca1a7973a664d608166f70ee0e69739c77b4410
[/share/Multimedia/2024-MyProgramFiles/4.mp4jpg] #

总结

如果在 Windows  上使用, 需要下载 FFmpeg 官网:https://www.ffmpeg.org/  只要 ffmpeg.exe 就可以,放到一个有 %PATH 中记录的路径就好: 它就仍在一个电子书reader目录下。

Z:\2024-MyProgramFiles\4.mp4jpg>where ffmpeg
C:\Program Files\Calibre2\ffmpeg.exe

视频有很大上升空间,有空再说。

这次之所以快, VCS 上安装了 AI,代码自己跳:

vcs ai 自用演示

深刻感到,与国外的技术的差距,是自己人做的壁垒。  网速,封禁网站,内容审核。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值