From 749a36b445bf30f26158738104ea0f5903c78a68 Mon Sep 17 00:00:00 2001 From: Heorhii Mykhailenko Date: Tue, 6 Jun 2023 18:18:12 +0300 Subject: [PATCH] Initial commit --- .gitignore | 131 +++++++++++++++++++++++++++++++++++ README.md | 14 ++++ config-example.ini | 6 ++ mail.py | 53 ++++++++++++++ main.py | 152 +++++++++++++++++++++++++++++++++++++++++ requirements.txt | 21 ++++++ templates/index.html | 17 +++++ templates/success.html | 15 ++++ templates/video.html | 45 ++++++++++++ 9 files changed, 454 insertions(+) create mode 100755 .gitignore create mode 100755 README.md create mode 100755 config-example.ini create mode 100755 mail.py create mode 100755 main.py create mode 100755 requirements.txt create mode 100755 templates/index.html create mode 100755 templates/success.html create mode 100755 templates/video.html diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..e4b0aa2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +config.ini diff --git a/README.md b/README.md new file mode 100755 index 0000000..e64ea36 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Video processing service + +This program is a video processing service. It is a project for New Generation programming courses. + +## Features +- uploading a video +- selecting a part of a video to cut and work with +- converting a video to another format +- adding a watermark image to a video +- sending the result to an email address +- displaying a progress bar + +## Usage +Before running the program, a file `config-example.ini` must be renamed to `config.ini` and be filled with valid SMTP server credentials. diff --git a/config-example.ini b/config-example.ini new file mode 100755 index 0000000..49e746f --- /dev/null +++ b/config-example.ini @@ -0,0 +1,6 @@ +[Mail] +Server = localhost +Port = 465 +Username = sender@localhost +Password = 123456 +SSL = True diff --git a/mail.py b/mail.py new file mode 100755 index 0000000..5f4ec2d --- /dev/null +++ b/mail.py @@ -0,0 +1,53 @@ +import configparser, email, os, smtplib, ssl + +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +config = configparser.ConfigParser() +config.read('config.ini') + +def send_email(destination, attachment): + subject = 'Your modified video' + body = 'You have recently used our service to modify a video. The result is attached.' + sender_email = config.get('Mail', 'Username') + receiver_email = destination + password = config.get('Mail', 'Password') + + # Create a multipart message and set headers + message = MIMEMultipart() + message['From'] = sender_email + message['To'] = receiver_email + message['Subject'] = subject + + # Add body to email + message.attach(MIMEText(body, 'plain')) + + with open(attachment, 'rb') as file: + part = MIMEBase('application', 'octet-stream') + part.set_payload(file.read()) + + # Encode file in ASCII characters to send by email + encoders.encode_base64(part) + + # Add header as key/value pair to attachment part + part.add_header( + 'Content-Disposition', + f'attachment; filename= {os.path.normpath(attachment)}', + ) + + # Add attachment to message and convert message to string + message.attach(part) + text = message.as_string() + + # Log in to server using secure context and send email + if config.getboolean('Mail', 'SSL'): + context = ssl.create_default_context() + with smtplib.SMTP_SSL(config.get('Mail', 'Server'), config.get('Mail', 'Port'), context=context) as server: + server.login(sender_email, password) + server.sendmail(sender_email, receiver_email, text) + else: + with smtplib.SMTP(config.get('Mail', 'Server'), config.get('Mail', 'Port')) as server: + server.login(sender_email, password) + server.sendmail(sender_email, receiver_email, text) diff --git a/main.py b/main.py new file mode 100755 index 0000000..a1fcda6 --- /dev/null +++ b/main.py @@ -0,0 +1,152 @@ +from mail import send_email +from flask import Flask, abort, render_template, redirect, request, url_for +from PIL import Image +from proglog import ProgressBarLogger +import os +import uuid + +import moviepy.editor as moviepy + +app = Flask(__name__) + +ALLOWED_UPLOAD_EXTENSIONS = {'mp4', 'webm', 'mkv', 'avi', 'ogv'} +ALLOWED_CONVERT_EXTENSIONS = {'mp4', 'webm', 'mkv', 'avi', 'ogv', 'ogg', 'mp3', 'flac'} +AUDIO_EXTENSIONS = {'ogg', 'mp3', 'flac'} +ALLOWED_WATERMARK_EXTENSIONS = {'bmp', 'png', 'jpg', 'jpeg', 'tiff', 'tga', 'svg'} + +percentages = {} + +class BarLogger(ProgressBarLogger): + global percentages + id = None + + def bars_callback(self, bar, attr, value, old_value=None): + percentages[self.id] = (value / self.bars[bar]['total']) * 100 + + def __init__(self, id): + super(self.__class__, self).__init__() + self.id = id + +def is_integer(n): + try: + return float(n).is_integer() + except ValueError: + return False + +def getExtension(filename, isWatermark=False): + if not '.' in filename: + return False + extension = filename.rsplit('.', 1)[1].lower() + if extension in ALLOWED_UPLOAD_EXTENSIONS and not isWatermark: + return extension + elif extension in ALLOWED_WATERMARK_EXTENSIONS and isWatermark: + return extension + else: + return False + +@app.route('/', methods=['GET', 'POST']) +def index(): + if request.method == 'POST' and 'video' in request.files: + file = request.files['video'] + if not file.filename == '': + extension = getExtension(file.filename) + if extension != False: + filename = str(uuid.uuid4()) + '.' + extension + file.save(os.path.join('videos', filename)) + return redirect(url_for('video', id=filename)) + else: + return render_template('index.html', error='Non-allowed file extension.') + + return render_template('index.html') + +@app.route('/video', methods=['GET', 'POST']) +def video(): + error = '' + changed = False + audio = None + id = request.args.get('id') + path = os.path.join('videos', id) + if id != None and os.path.isfile(path): + clip = moviepy.VideoFileClip(path) + if request.method == 'POST': + start = request.form.get('start') + end = request.form.get('end') + extension = request.form.get('extension') + email = request.form.get('email') + if not email: + abort(400) + if start and end: + if is_integer(start) and is_integer(end) and int(start) < int(end): + if int(start) < 0 or int(end) > clip.duration: + error += 'Selected subclip out of bounds.
' + else: + clip = clip.subclip(start, end) + changed = True + if extension: + if extension in ALLOWED_CONVERT_EXTENSIONS: + if extension in AUDIO_EXTENSIONS: + audio = clip.audio + newId = id.rsplit('.', 1)[0] + '.' + extension + if id == newId: + error += 'A different extension than the currently used one is needed.
' + else: + changed = True + else: + error += 'Requested recode extension not allowed.
' + if 'watermark' in request.files: + file = request.files['watermark'] + if not file.filename == '': + if audio: + error += 'Audio cannot be watermarked.
' + else: + watermarkExtension = getExtension(file.filename, True) + if watermarkExtension: + watermarkPath = os.path.join('videos', id + '-watermark' + '.' + watermarkExtension) + file.save(watermarkPath) + + formatter = {'PNG': 'RGBA', 'JPEG': 'RGB'} + img = Image.open(watermarkPath) + rgbimg = Image.new(formatter.get(img.format, 'RGB'), img.size) + rgbimg.paste(img) + rgbimg.save(watermarkPath, format=img.format) + + watermark = (moviepy.ImageClip(watermarkPath) + .set_duration(clip.duration) + .set_pos(('right', 'bottom'))) + + clip = moviepy.CompositeVideoClip([clip, watermark]) + os.remove(watermarkPath) + changed = True + else: + error += 'Non-allowed watermark extension.
' + if changed: + logger = BarLogger(id) + if 'newId' in locals(): + os.remove(path) + id = newId + path = os.path.join('videos', id) + if audio: + audio.write_audiofile(path, logger=logger) + else: + clip.write_videofile(path, logger=logger) + send_email(email, path) + os.remove(path) + return render_template('success.html', error=error) + + return render_template('video.html', id=id, length=int(clip.duration), error=error) + else: + abort(404) + +@app.route('/progress', methods=['GET']) +def progress(): + id = request.args.get('id') + percentage = percentages.get(id) + if percentage: + return str(percentage) + else: + abort(404) + +if __name__ == '__main__': + if not os.path.isdir('videos'): + os.mkdir('videos') + app.run() diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..a4e4c31 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +blinker==1.6.2 +certifi==2023.5.7 +charset-normalizer==3.1.0 +click==8.1.3 +configparser==5.3.0 +decorator==4.4.2 +Flask==2.3.2 +idna==3.4 +imageio==2.31.0 +imageio-ffmpeg==0.4.8 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.2 +moviepy==1.0.3 +numpy==1.24.3 +Pillow==9.5.0 +proglog==0.1.10 +requests==2.31.0 +tqdm==4.65.0 +urllib3==2.0.2 +Werkzeug==2.3.3 diff --git a/templates/index.html b/templates/index.html new file mode 100755 index 0000000..555ccb8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,17 @@ + + + + Video upload + + + {% if error %} +

{{ error | safe }}

+ {% endif %} +
+
+
+ +
+ +

+ diff --git a/templates/success.html b/templates/success.html new file mode 100755 index 0000000..9d2346f --- /dev/null +++ b/templates/success.html @@ -0,0 +1,15 @@ + + + + Video upload + + +

The modified video was sent to your email address. You can upload another one if you want to.

+ {% if error %} +

The following errors occurred:
+ {{ error | safe }}

+ {% endif %} +

Upload another video

+ +

+ diff --git a/templates/video.html b/templates/video.html new file mode 100755 index 0000000..4fda2c6 --- /dev/null +++ b/templates/video.html @@ -0,0 +1,45 @@ + + + + Video editing + + + {% if error %} +

{{ error | safe }}

+ {% endif %} +

Video {{name}} ({{length}})

+
+
+
+
+
+
+
+
+
+
+
+ +
+ 0 % + + + +