原因:
上一版的界面太简单,功能也是,如下图:素
原文章发表于3个月前:<Project-4 2mp4jpg> Python Coding Flask应用:将视频与图片转换为MP4/JPG应用 主要用到 ffmpeg PIL
用它在处理图片时功能不够,还会用 ACDsee 去编辑。 其实就是从 Mid journey 下载图片,然后改大小,切个图做 logo,作应用的图标。一是在我的 Portal 上使用,二是在这里发文章当主图。
当前界面
也素,但菜单复杂显着牛B,主要使用 FFmpeg 实现。
一、图片转换功能(左)
- 支持 JPG, PNG, GIF, BMP, WebP 图片文件格式
- 有图片预览功能
- 在预览区可以裁剪图片:自由裁剪,或 1:1裁剪
- 图片的输入支持原格式、PNG JPG WebP 之间选择
- 上传图片是 PNG 格式,在输出格式会默认为 JPG
- 图片大小:原尺寸、一半儿、96x96(chrome browser logo)、自定义大小
- 在自定义大小中:支持大小写的 x 符号,* (星) 符号
- 可以预览转换后图片
- 转换后图片支持下载
- 显示原图片信息:分辨率、文件大小、格式
- 显示转换后的图片信息:分辨率、文件大小、格式
- 点击转换后,不清除原文件,可以继续重新操作,直到重新选择图片
演示:图片转换
二、视频转换功能(右)
- 支持转换的视频格式:MP4, AVI, MKV, MOV, WMV
- 文件最大支持 7GB (原本是 100MB, 做起来已经够慢的)
- 输出主要是为了小,格式支持:MP4 H.256, WebP(v8/v9),WebA(这个是音频),AVI
- 压缩质量:高中低,对应于文件大质量好,中庸,低质小文件
- 多种分辨率在菜单中可以选择:原大小, 1/2大小, 1/4 大小,一些常见的参数 720P,1080P,480P,4K(有几个极少用,但参考时有,一起复制)
- 支持自定义分辨率
- 默认支持锁定视频“横纵比”
- 原视频支持预览播放
- 显示原视频信息:分辨率,时长,文件大小,视频格式,帧率FPS,比特率
- 转换后视频信息:同上
完整代码
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 自用演示
深刻感到,与国外的技术的差距,是自己人做的壁垒。 网速,封禁网站,内容审核。