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
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "pymontage",
"runtimeExecutable": "venv\\Scripts\\python.exe",
"runtimeArgs": ["app.py"],
"port": 5000
}
]
}
210 changes: 210 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
import requests
import json
import tempfile
import glob
import re
import threading
from PIL import Image
from timeline_renderer import render_project
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

Expand All @@ -14,6 +19,45 @@
app.config['OUTPUT_FOLDER'] = 'temp_outputs'
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True)
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 ** 3 # 2 GB upload cap

UUID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
MEDIA_EXTS = {
'video': {'.mp4', '.mov', '.webm', '.mkv'},
'audio': {'.mp3', '.wav', '.m4a', '.flac', '.ogg'},
'image': {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tif', '.tiff',
'.webp', '.heic', '.heif'},
}


def media_type_for(ext):
for mtype, exts in MEDIA_EXTS.items():
if ext in exts:
return mtype
return None


def resolve_media_path(session, media_id):
"""Return the stored file for a media id, or None. Validates both parts."""
if not UUID_RE.match(session or '') or not re.match(r'^[0-9a-f]{32}$', media_id or ''):
return None
folder = os.path.join(app.config['UPLOAD_FOLDER'], session)
matches = [p for p in glob.glob(os.path.join(folder, media_id + '.*'))
if not p.endswith('.thumb.jpg')]
return matches[0] if matches else None


def probe_media(path, mtype):
if mtype == 'image':
with Image.open(path) as im:
return {'duration': 5.0, 'width': im.width, 'height': im.height}
from moviepy.editor import AudioFileClip, VideoFileClip
if mtype == 'video':
with VideoFileClip(path) as clip:
return {'duration': round(clip.duration, 3),
'width': clip.w, 'height': clip.h}
with AudioFileClip(path) as clip:
return {'duration': round(clip.duration, 3), 'width': 0, 'height': 0}


def cleanup_temp_folders():
Expand Down Expand Up @@ -123,6 +167,172 @@ def download_font():
return jsonify({'success': True, 'path': path, 'name': name})
return jsonify({'success': False, 'message': 'Could not download font'}), 502

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


@app.route('/api/media', methods=['POST'])
def upload_media():
session = request.form.get('session', '')
if not UUID_RE.match(session):
return jsonify({'error': 'Invalid session'}), 400
file = request.files.get('file')
if not file or not file.filename:
return jsonify({'error': 'No file'}), 400
ext = os.path.splitext(file.filename)[1].lower()
mtype = media_type_for(ext)
if not mtype:
return jsonify({'error': f'Unsupported file type: {ext}'}), 400

folder = os.path.join(app.config['UPLOAD_FOLDER'], session)
os.makedirs(folder, exist_ok=True)
media_id = uuid.uuid4().hex
path = os.path.join(folder, media_id + ext)
file.save(path)
try:
meta = probe_media(path, mtype)
except Exception as e:
os.remove(path)
return jsonify({'error': f'Could not read file: {e}'}), 400
return jsonify({'id': media_id, 'name': file.filename, 'type': mtype,
'url': f'/api/media/{session}/{media_id}', **meta})


@app.route('/api/media/<session>/<media_id>')
def get_media(session, media_id):
path = resolve_media_path(session, media_id)
if not path:
return jsonify({'error': 'Not found'}), 404
return send_file(path, conditional=True) # conditional=True → Range support


@app.route('/api/media/<session>/<media_id>/thumb')
def get_media_thumb(session, media_id):
path = resolve_media_path(session, media_id)
if not path:
return jsonify({'error': 'Not found'}), 404
mtype = media_type_for(os.path.splitext(path)[1].lower())
if mtype == 'audio':
return jsonify({'error': 'No thumbnail for audio'}), 404
thumb = os.path.join(os.path.dirname(path), media_id + '.thumb.jpg')
if not os.path.exists(thumb):
if mtype == 'image':
with Image.open(path) as im:
im = im.convert('RGB')
im.thumbnail((160, 160))
im.save(thumb, 'JPEG')
else:
ffmpeg = shutil.which('ffmpeg')
if not ffmpeg:
import imageio_ffmpeg
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
import subprocess
subprocess.run([ffmpeg, '-y', '-ss', '0.5', '-i', path,
'-frames:v', '1', '-vf', 'scale=160:-2', thumb],
capture_output=True, timeout=30)
if not os.path.exists(thumb):
return jsonify({'error': 'Thumbnail failed'}), 500
return send_file(thumb, mimetype='image/jpeg')


EXPORT_JOBS = {}
# ponytail: global lock, one export at a time; job queue if concurrent users ever matter
EXPORT_LOCK = threading.Lock()


def _validate_export(data):
"""Returns (session, project, media_paths) or raises ValueError."""
session = data.get('session', '')
project = data.get('project')
if not UUID_RE.match(session) or not isinstance(project, dict):
raise ValueError('Invalid session or project')
for key in ('settings', 'media', 'tracks'):
if key not in project:
raise ValueError(f'Project missing "{key}"')
s = project['settings']
if not (16 <= int(s.get('width', 0)) <= 7680 and
16 <= int(s.get('height', 0)) <= 4320 and
1 <= int(s.get('fps', 0)) <= 120):
raise ValueError('Invalid project settings')
used_ids = {c['mediaId'] for t in project['tracks'] for c in t['clips']}
media_paths = {}
for mid in used_ids:
path = resolve_media_path(session, mid)
if not path:
raise ValueError(f'Media {mid} not found on server')
media_paths[mid] = path
return session, project, media_paths


@app.route('/api/export', methods=['POST'])
def start_export():
data = request.get_json(silent=True) or {}
try:
session, project, media_paths = _validate_export(data)
except (ValueError, KeyError, TypeError) as e:
return jsonify({'error': str(e)}), 400

if not EXPORT_LOCK.acquire(blocking=False):
return jsonify({'error': 'An export is already running'}), 409

job_id = uuid.uuid4().hex
output_path = os.path.join(app.config['OUTPUT_FOLDER'], f'export_{job_id}.mp4')
EXPORT_JOBS[job_id] = {'state': 'running', 'percent': 0,
'error': None, 'path': output_path}

def run():
job = EXPORT_JOBS[job_id]
try:
render_project(project, media_paths, output_path,
progress_cb=lambda p: job.update(percent=int(p * 100)))
job.update(state='done', percent=100)
except Exception as e:
job.update(state='failed', error=str(e))
if os.path.exists(output_path):
try:
os.remove(output_path)
except OSError:
pass
finally:
EXPORT_LOCK.release()

threading.Thread(target=run, daemon=True).start()
return jsonify({'job': job_id})


@app.route('/api/export/<job_id>/status')
def export_status(job_id):
job = EXPORT_JOBS.get(job_id)
if not job:
return jsonify({'error': 'Unknown job'}), 404
return jsonify({'state': job['state'], 'percent': job['percent'],
'error': job['error']})


@app.route('/api/export/<job_id>/download')
def export_download(job_id):
job = EXPORT_JOBS.get(job_id)
if not job:
return jsonify({'error': 'Unknown job'}), 404
if job['state'] != 'done':
return jsonify({'error': 'Not finished'}), 409
path = job['path']
if not os.path.exists(path):
return jsonify({'error': 'File gone'}), 404

@after_this_request
def cleanup(response):
try:
os.remove(path)
except OSError:
pass
EXPORT_JOBS.pop(job_id, None)
return response

return send_file(path, as_attachment=True, download_name='export.mp4')


@app.route('/preview', methods=['POST'])
def preview():
"""Generate a preview video"""
Expand Down
Loading
Loading