Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ ENV/
temp_uploads/
temp_outputs/

# Downloaded Fonts (regenerated on demand)
assets/fonts/

# Python Cache
__pycache__/
*.py[cod]
*$py.class
*.so
*.pyc
*.pyo
.pytest_cache/

# Distribution / Packaging
.Python
Expand Down Expand Up @@ -54,6 +59,10 @@ Thumbs.db
# Video Cache
*.cache

# MoviePy Temp Files
TEMP/
*.tmp

# Test Files
test_output/
test_images/
Expand Down
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# 🎬 PyMontage - Automatic Video Slideshow Creator

![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)
![Version](https://img.shields.io/badge/version-1.0.1-blue.svg)
![Python](https://img.shields.io/badge/python-3.8+-blue.svg)
![Flask](https://img.shields.io/badge/flask-3.0.0-green.svg)
![License](https://img.shields.io/badge/license-MIT-orange.svg)

**PyMontage** is a powerful web-based application that automatically creates professional video slideshows from your photos and music. Upload your images, select a soundtrack, and let PyMontage do the rest!

**Version 1.0 Release** - Production ready with full feature set including audio crossfading, smart layouts, and comprehensive customization options.
**Version 1.0.1 Release** - Enhanced with Google Fonts integration, automatic server cleanup, and improved font management system.

## ✨ Features

Expand All @@ -18,7 +18,9 @@
- 📊 **Real-time Progress Bar**: Track video creation progress with detailed status updates
- ⚙️ **Full Customization**: Control resolution, timing, transitions, fonts, and quality settings
- 🎬 **Professional Output**: High-quality video output with smooth transitions and title cards
- 🔤 **Custom Fonts**: Choose from multiple font options for titles and overlays
- 🔤 **Custom Fonts**: Choose from built-in Windows fonts or download from Google Fonts
- 🔎 **Font Search**: Search and instantly download any font from Google Fonts library
- 🧹 **Auto Cleanup**: Automatic cleanup of temporary files and cache on shutdown
- 🌐 **Web Interface**: Easy-to-use browser-based interface with drag-and-drop support
- 📱 **Format Support**: Supports JPG, PNG, HEIC, GIF, BMP, TIFF, WebP, and more
- 🎥 **Hardware Acceleration**: Automatic GPU detection for faster rendering (NVIDIA NVENC)
Expand Down Expand Up @@ -137,19 +139,22 @@
- Bitrate: 2000k (low) to 15000k (ultra)
- CRF Quality: 18 (best quality) to 32 (smaller file)
- **Text Styling**:
- Font Family: Choose from 10+ Windows fonts
- Font Family: Choose from built-in Windows fonts or search and download from Google Fonts
- Title Font Size: Size for intro/outro text (30-200)
- Date Font Size: Size for date overlays (20-150)
- Font Search: Live search to find and download fonts from Google Fonts library
- **Image Processing**:
- Max Image Width: Downscale images to save memory (1920-4800 pixels)

## 🛠️ Technical Details

### Architecture
- **Backend**: Flask (Python web framework)
- **Backend**: Flask (Python web framework) with signal handlers for graceful shutdown
- **Video Processing**: MoviePy, OpenCV, FFmpeg
- **Audio Analysis**: librosa
- **Frontend**: HTML5, Bootstrap 5, vanilla JavaScript
- **Frontend**: HTML5, Bootstrap 5, vanilla JavaScript with async font management
- **Font Management**: Google Fonts API integration with local caching and automatic downloads
- **Cleanup System**: Automatic cleanup of temporary files, cache, and bytecode on exit

### File Structure
```
Expand Down
195 changes: 181 additions & 14 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,215 @@
import uuid
import atexit
import signal
from flask import Flask, render_template, request, send_file, after_this_request
from video_engine import generate_video_from_web # Import from video_engine.py
import requests
import json
import tempfile
from flask import Flask, render_template, request, send_file, after_this_request, jsonify
from video_engine import generate_video_from_web, install_google_font # Import from video_engine.py

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'temp_uploads'
app.config['OUTPUT_FOLDER'] = 'temp_outputs'

# Create temporary folders if they don't exist
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True)


def cleanup_temp_folders():
"""Clean up all temporary folders when server shuts down"""
print("\n🧹 Cleaning up temporary folders...")
"""Clean up all temporary folders and cache when server shuts down"""
print("\n🧹 Cleaning up temporary files and caches...")

folders_to_remove = [
app.config['UPLOAD_FOLDER'],
app.config['OUTPUT_FOLDER'],
'__pycache__',
'.pytest_cache',
]

try:
if os.path.exists(app.config['UPLOAD_FOLDER']):
shutil.rmtree(app.config['UPLOAD_FOLDER'])
print(f" ✓ Removed {app.config['UPLOAD_FOLDER']}")
if os.path.exists(app.config['OUTPUT_FOLDER']):
shutil.rmtree(app.config['OUTPUT_FOLDER'])
print(f" ✓ Removed {app.config['OUTPUT_FOLDER']}")
print("✓ Cleanup complete!")
# Remove temp folders
for folder in folders_to_remove:
if os.path.exists(folder):
if os.path.isdir(folder):
shutil.rmtree(folder)
print(f" ✓ Removed {folder}/")
else:
os.remove(folder)
print(f" ✓ Removed {folder}")

# Remove .pyc files recursively
for root, dirs, files in os.walk('.'):
for file in files:
if file.endswith('.pyc') or file.endswith('.pyo'):
filepath = os.path.join(root, file)
try:
os.remove(filepath)
except:
pass
Comment on lines +48 to +49
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bare except clause silently catches all exceptions including KeyboardInterrupt and SystemExit. Replace with specific exception types such as 'except OSError:' or at minimum 'except Exception:'.

Copilot uses AI. Check for mistakes.
print(" ✓ Removed .pyc and .pyo files")

# Remove moviepy temp files
temp_dir = tempfile.gettempdir()
for file in os.listdir(temp_dir):
if 'mpy' in file.lower():
try:
filepath = os.path.join(temp_dir, file)
if os.path.isfile(filepath):
os.remove(filepath)
Comment on lines +54 to +59
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup logic iterates through all files in the system's temporary directory and removes files containing 'mpy' in the filename. This is too broad and could accidentally delete unrelated files from other applications. The check should be more specific to MoviePy's actual temporary file patterns.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +59
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup logic for MoviePy temp files at lines 54-61 is too broad and potentially dangerous. It removes any file in the system temp directory containing 'mpy' in the filename (case-insensitive). This could accidentally delete unrelated files from other applications or user data that happens to contain 'mpy' in the name. Consider using a more specific pattern like 'moviepy_' or checking for specific MoviePy temporary file patterns.

Suggested change
if 'mpy' in file.lower():
try:
filepath = os.path.join(temp_dir, file)
if os.path.isfile(filepath):
os.remove(filepath)
filepath = os.path.join(temp_dir, file)
# Only remove files that match MoviePy's known temp filename prefix
if os.path.isfile(filepath) and file.lower().startswith('moviepy_'):
try:
os.remove(filepath)

Copilot uses AI. Check for mistakes.
except:
pass
Comment on lines +60 to +61
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bare except clauses silently catch all exceptions including KeyboardInterrupt and SystemExit. This can make debugging difficult and could hide serious errors. Replace with specific exception types such as 'except OSError:' or at minimum 'except Exception:'.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +61
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bare except: clauses at lines 48 and 60 silently swallow all exceptions including KeyboardInterrupt and SystemExit. This makes debugging difficult and can mask serious errors. Consider catching specific exceptions like OSError or PermissionError instead, or at minimum use except Exception: to avoid catching system-level exceptions.

Copilot uses AI. Check for mistakes.
print(" ✓ Cleaned moviepy temp files")

print("✓ Full cleanup complete!")
except Exception as e:
print(f"⚠ Warning: Could not clean up temp folders: {e}")
print(f"⚠ Warning: Could not complete full cleanup: {e}")


def signal_handler(signum, frame):
"""Handle termination signals (Ctrl+C, etc.)"""
print(f"\n\n🛑 Received signal {signum}, shutting down server...")
cleanup_temp_folders()
exit(0)


# Register cleanup handlers
atexit.register(cleanup_temp_folders)
signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
signal.signal(signal.SIGTERM, signal_handler) # Termination signal


@app.route('/')
def index():
return render_template('index.html')


@app.route('/fonts/search')
def search_fonts():
query = request.args.get('q', '').strip().lower()
if not query:
return jsonify({'fonts': []})

try:
resp = requests.get('https://fonts.google.com/metadata/fonts', timeout=10)
resp.raise_for_status()
content = resp.text
if content.startswith(")]}'"):
content = content[5:]
data = json.loads(content)
Comment on lines +94 to +99
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Google Fonts metadata endpoint is called on every search request without any caching. This could cause performance issues and rate limiting. Consider implementing a caching mechanism (e.g., cache the font list for a reasonable duration like 24 hours) to reduce external API calls and improve response times.

Copilot uses AI. Check for mistakes.
except Exception:
return jsonify({'fonts': [], 'error': 'Failed to reach Google Fonts'}), 502

items = data.get('familyMetadataList', []) if isinstance(data, dict) else []
matches = []
for item in items:
name = item.get('family', '')
if query in name.lower():
matches.append(name)
if len(matches) >= 15:
break
return jsonify({'fonts': matches})
Comment on lines +93 to +111
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The font search endpoint lacks error handling for network issues. If requests.get() raises an exception (lines 94-101), it returns a 502 status with a generic error message. However, the function doesn't validate the JSON response structure before accessing nested keys. If Google changes their API response format, the code could crash with KeyError or AttributeError. Consider adding validation after parsing the JSON to ensure data has the expected structure.

Copilot uses AI. Check for mistakes.


@app.route('/fonts/download', methods=['POST'])
def download_font():
payload = request.get_json(silent=True) or {}
name = payload.get('name') or request.form.get('name')
if not name:
return jsonify({'success': False, 'message': 'Font name required'}), 400

path = install_google_font(name)
if path and os.path.exists(path):
return jsonify({'success': True, 'path': path, 'name': name})
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The font download endpoint returns the absolute local filesystem path to the frontend. This exposes internal server paths to clients. Consider returning just the filename or a relative path instead, and handle path resolution on the server side when processing the font selection.

Suggested change
return jsonify({'success': True, 'path': path, 'name': name})
# Return a relative path instead of an absolute filesystem path
relative_path = os.path.relpath(path, start=os.getcwd())
return jsonify({'success': True, 'path': relative_path, 'name': name})

Copilot uses AI. Check for mistakes.
return jsonify({'success': False, 'message': 'Could not download font'}), 502

@app.route('/preview', methods=['POST'])
def preview():
"""Generate a preview video"""
session_id = str(uuid.uuid4())
session_upload_path = os.path.join(app.config['UPLOAD_FOLDER'], session_id)
session_images_path = os.path.join(session_upload_path, 'images')
os.makedirs(session_images_path, exist_ok=True)

try:
# Save audio file
if 'audio' not in request.files:
return jsonify({'success': False, 'message': 'No audio file'}), 400
audio_file = request.files['audio']
audio_path = os.path.join(session_upload_path, 'music.mp3')
audio_file.save(audio_path)

# Save images
if 'images' not in request.files:
return jsonify({'success': False, 'message': 'No images'}), 400
files = request.files.getlist('images')
for file in files:
if file.filename:
file.save(os.path.join(session_images_path, file.filename))
Comment on lines +146 to +148
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The uploaded filenames are used directly to save files without sanitization. This could allow directory traversal attacks if a malicious filename like '../../sensitive.mp3' is uploaded. Sanitize filenames using a function like werkzeug.utils.secure_filename() before saving.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback


# Generate preview
success, message = generate_video_from_web(
image_folder=session_images_path,
audio_paths=[audio_path],
output_path=os.path.join(app.config['OUTPUT_FOLDER'], f'preview_{session_id}.mp4'),
intro_text=request.form.get('intro_text', 'Preview'),
outro_text=request.form.get('outro_text', 'End'),
config={}
)
if success:
return jsonify({'success': True, 'filename': f'preview_{session_id}.mp4'})
else:
return jsonify({'success': False, 'message': message}), 500

except Exception as e:
print(f"Preview error: {e}")
return jsonify({'success': False, 'message': str(e)}), 500
finally:
# Clean up uploads
try:
if os.path.exists(session_upload_path):
shutil.rmtree(session_upload_path)
except Exception:
pass
Comment on lines +172 to +173
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup logic in the finally block (lines 169-173) uses a bare except Exception: that silently ignores all cleanup failures. While this prevents the endpoint from crashing, it could lead to accumulation of temporary files if cleanup consistently fails. Consider logging the exception to help diagnose persistent cleanup issues.

Suggested change
except Exception:
pass
except Exception as cleanup_error:
print(f"Cleanup error for {session_upload_path}: {cleanup_error}")

Copilot uses AI. Check for mistakes.

@app.route('/get_preview/<filename>')
def get_preview(filename):
"""Stream preview video"""
# Validate the filename to prevent path traversal
if not filename:
return "Invalid filename", 400

# Disallow any path separators or parent directory references
if os.path.sep in filename or (os.path.altsep and os.path.altsep in filename) or '..' in filename:
return "Invalid filename", 400

# Enforce expected naming pattern for preview files
if not (filename.startswith('preview_') and filename.endswith('.mp4')):
return "Invalid filename", 400

# Build absolute paths and ensure the target stays within OUTPUT_FOLDER
output_folder = os.path.abspath(app.config['OUTPUT_FOLDER'])
preview_path = os.path.abspath(os.path.join(output_folder, filename))

if not preview_path.startswith(output_folder + os.path.sep):
Comment on lines +190 to +194
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path traversal validation logic has an issue. On Windows, os.path.sep is typically \, so the check at line 194 if not preview_path.startswith(output_folder + os.path.sep) would fail for the last file in the directory if output_folder doesn't end with a separator. A safer approach is to use os.path.commonpath() or ensure both paths are normalized with trailing separators. Additionally, os.path.altsep is None on many platforms, so the check if os.path.altsep and os.path.altsep in filename could be simplified.

Copilot uses AI. Check for mistakes.
return "Invalid filename", 400
if os.path.exists(preview_path):
return send_file(preview_path, mimetype='video/mp4')
return "Preview not found", 404
Comment on lines +126 to +198
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /preview endpoint (lines 126-173) and /get_preview/<filename> endpoint (lines 175-198) are defined but don't appear to be called from the frontend HTML. Additionally, these endpoints call generate_video_from_web instead of the new generate_video_preview function that was added to video_engine.py. This suggests either: 1) the endpoints are incomplete/untested, 2) they're intended for future use but shouldn't be in this release, or 3) the frontend integration is missing. Consider either completing the feature integration or removing these endpoints until they're fully implemented.

Copilot uses AI. Check for mistakes.

@app.route('/create', methods=['POST'])
def create_video():
# Clean up ALL preview files when creating final video
try:
for f in os.listdir(app.config['OUTPUT_FOLDER']):
if f.startswith('preview_') and f.endswith('.mp4'):
file_path = os.path.join(app.config['OUTPUT_FOLDER'], f)
try:
os.remove(file_path)
print(f"🗑️ Cleaned up preview: {f}")
except:
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the cleanup handler, this code uses a bare except: clause at line 210 that catches all exceptions including system-level ones. Use except Exception: or specific exception types like OSError instead.

Suggested change
except:
except OSError:

Copilot uses AI. Check for mistakes.
pass
Comment on lines +210 to +211
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bare except clause silently catches all exceptions. Replace with specific exception types such as 'except OSError:' or at minimum 'except Exception:'.

Copilot uses AI. Check for mistakes.
except Exception as e:
print(f"⚠️ Could not clean previews: {e}")

# Create unique session ID to avoid conflicts
session_id = str(uuid.uuid4())
session_upload_path = os.path.join(app.config['UPLOAD_FOLDER'], session_id)
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ numpy>=1.24.0
tqdm>=4.66.0
librosa>=0.10.0
pillow-heif>=0.13.0
requests>=2.31.0
setuptools>=65.0.0
Loading