Pix360 core app - current status
This commit is contained in:
commit
dd11adcced
38 changed files with 7783 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
db.sqlite3
|
||||||
|
settings.ini
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
venv/
|
||||||
|
.vscode/
|
0
LICENSE
Normal file
0
LICENSE
Normal file
0
README.md
Normal file
0
README.md
Normal file
26
pyproject.toml
Normal file
26
pyproject.toml
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "pix360core"
|
||||||
|
version = "0.0.1"
|
||||||
|
authors = [
|
||||||
|
{ name="Kumi Systems e.U.", email="office@kumi.systems" },
|
||||||
|
]
|
||||||
|
description = "Core features of PIX360"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"cube2sphere",
|
||||||
|
"pillow"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://kumig.it/kumisystems/pix360core"
|
||||||
|
"Bug Tracker" = "https://kumig.it/kumisystems/pix360core/issues"
|
0
src/pix360core/__init__.py
Normal file
0
src/pix360core/__init__.py
Normal file
10
src/pix360core/admin.py
Normal file
10
src/pix360core/admin.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
|
from .models import User, Conversion, File
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.unregister(Group)
|
||||||
|
admin.site.register(User)
|
||||||
|
admin.site.register(Conversion)
|
||||||
|
admin.site.register(File)
|
6
src/pix360core/apps.py
Normal file
6
src/pix360core/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class Pix360CoreConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "pix360core"
|
12
src/pix360core/backends.py
Normal file
12
src/pix360core/backends.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
|
||||||
|
|
||||||
|
from .models.auth import User
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCBackend(OIDCAuthenticationBackend):
|
||||||
|
def create_user(self, claims):
|
||||||
|
email = claims.get('email')
|
||||||
|
return self.UserModel.objects.create_user(email)
|
||||||
|
|
||||||
|
def get_username(self, claims):
|
||||||
|
return claims.get('email')
|
4
src/pix360core/classes/__init__.py
Normal file
4
src/pix360core/classes/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from .modules import BaseModule, DownloaderModule
|
||||||
|
from .exceptions import DownloadError, StitchingError, ConversionError
|
||||||
|
from .http import HTTPRequest
|
||||||
|
from .stitching import BaseStitcher, PILStitcher, BlenderStitcher, DEFAULT_CUBEMAP_TO_EQUIRECTANGULAR_STITCHER, DEFAULT_STITCHER
|
14
src/pix360core/classes/exceptions.py
Normal file
14
src/pix360core/classes/exceptions.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
class ConversionError(Exception):
|
||||||
|
"""Generic error that occurred while attempting to convert content
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DownloadError(ConversionError):
|
||||||
|
"""Generic error that occurred while attempting to download content
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class StitchingError(ConversionError):
|
||||||
|
"""Generic error that occurred while attempting to stitch content
|
||||||
|
"""
|
||||||
|
pass
|
25
src/pix360core/classes/http.py
Normal file
25
src/pix360core/classes/http.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from .exceptions import DownloadError
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
USER_AGENT = 'Mozilla/5.0 (compatible; Pix360/dev; +https://kumig.it/kumisystems/pix360)'
|
||||||
|
|
||||||
|
class HTTPRequest(Request):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.logger = logging.getLogger("pix360")
|
||||||
|
|
||||||
|
self.headers['User-Agent'] = USER_AGENT
|
||||||
|
|
||||||
|
def open(self, retries=3, timeout=10, *args, **kwargs):
|
||||||
|
self.logger.debug(f"Opening {self.full_url}")
|
||||||
|
for i in range(retries):
|
||||||
|
try:
|
||||||
|
return urlopen(self, timeout=timeout, *args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warn(f"Error while opening {self.full_url}: {e}")
|
||||||
|
if i == retries - 1:
|
||||||
|
raise DownloadError(f"Error downloading file from {self.full_url}") from e
|
60
src/pix360core/classes/modules.py
Normal file
60
src/pix360core/classes/modules.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
from ..models import File, Conversion
|
||||||
|
|
||||||
|
class BaseModule:
|
||||||
|
"""Base class for any type of modules supported by PIX360
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
identifier: str
|
||||||
|
|
||||||
|
|
||||||
|
class DownloaderModule(BaseModule):
|
||||||
|
"""Base class for modules that handle downloading content from a URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Certainty levels for the test_url() method
|
||||||
|
|
||||||
|
CERTAINTY_UNSUPPORTED = -100
|
||||||
|
CERTAINTY_POSSIBLE = 0
|
||||||
|
CERTAINTY_PROBABLE = 50
|
||||||
|
CERTAINTY_CERTAIN = 100
|
||||||
|
|
||||||
|
# Properties of the module
|
||||||
|
|
||||||
|
name: str # Human-friendly name of the module
|
||||||
|
identifier: str # Unique identifier for the module
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def test_url(cls, url: str) -> int:
|
||||||
|
"""Test if URL is plausible for this module
|
||||||
|
|
||||||
|
This should just match the URL against a regex or something like that,
|
||||||
|
it is not intended to check whether the URL is valid and working, or
|
||||||
|
whether it actually contains downloadable content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): URL to check for plausibility
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotImplementedError: If the method is not implemented in a module
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Certainty level of the URL being supported by this module
|
||||||
|
See CERTAINTY_* constants for default values
|
||||||
|
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(f"Downloader Module {cls.__name__} does not implement test_url(url)!")
|
||||||
|
|
||||||
|
def process_conversion(self, conversion: Conversion) -> File:
|
||||||
|
"""Attempt to download content for a conversion
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversion (Conversion): Conversion object to process
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DownloadError: If an error occurred while downloading content
|
||||||
|
NotImplementedError: If the method is not implemented in a module
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File: Image or Video object containing the downloaded file
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(f"Downloader Module {self.__class__.__name__} does not implement process_url(url)!")
|
301
src/pix360core/classes/stitching.py
Normal file
301
src/pix360core/classes/stitching.py
Normal file
|
@ -0,0 +1,301 @@
|
||||||
|
from ..models import File
|
||||||
|
from ..classes import StitchingError
|
||||||
|
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import PIL.Image
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
class BaseStitcher:
|
||||||
|
"""Base class for stitcher modules
|
||||||
|
"""
|
||||||
|
|
||||||
|
CUBEMAP_ORDER = ["back", "right", "front", "left", "up", "down"]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.logger = logging.getLogger("pix360")
|
||||||
|
|
||||||
|
def cubemap_to_equirectangular(self, files: List[File], rotation: Tuple[int, int, int] = (0, 0, 0)) -> File:
|
||||||
|
"""Stitch a cubemap into an equirectangular image
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files (List[File]): List of 6 files representing the 6 faces of the cubemap [back, right, front, left, up, down].
|
||||||
|
rotation (Tuple[int, int, int], optional): Rotation of the cubemap on x, y and z axes in degrees. Defaults to (0, 0, 0).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotImplementedError: If the method is not implemented in a module
|
||||||
|
StitchingError: If the stitching failed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File: File object containing the stitched image
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def stitch(self, files: List[List[File]]) -> File:
|
||||||
|
"""Stitch a list of images together
|
||||||
|
|
||||||
|
The input is a list of lists of images.
|
||||||
|
Each list of images is stitched into one line horizontally.
|
||||||
|
The resulting lines are then stitched together vertically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files (List[List[File]]): List of lists of files to stitch together
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotImplementedError: If the method is not implemented in a module
|
||||||
|
StitchingError: If the stitching failed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File: File object containing the stitched image
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def multistitch(self, tiles: List[List[List[File]]]) -> List[File]:
|
||||||
|
"""Stitch a list of lists of images together
|
||||||
|
|
||||||
|
The input is a list of lists of lists of images.
|
||||||
|
Each list of lists of images is stitched into one line horizontally.
|
||||||
|
The resulting lines are then stitched together vertically.
|
||||||
|
This is repeated for each list of lists of images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tiles (List[List[List[File]]]): List of lists of lists of files to stitch together
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotImplementedError: If the method is not implemented in a module
|
||||||
|
StitchingError: If the stitching failed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[File]: List of File objects containing the stitched images
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for tile in tiles:
|
||||||
|
result.append(self.stitch(tile))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
class BlenderStitcher(BaseStitcher):
|
||||||
|
"""Stitcher module using Blender to stitch images
|
||||||
|
"""
|
||||||
|
def __init__(self, cube2sphere_path: Optional[str] = None):
|
||||||
|
"""Initialize the BlenderStitcher
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cube2sphere_path (Optional[str], optional): Path to the cube2sphere binary. Defaults to None, which will try to find the binary in the PATH.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.cube2sphere_path = cube2sphere_path or "cube2sphere"
|
||||||
|
|
||||||
|
def cubemap_to_equirectangular(self, files: List[File], rotation: Tuple[int, int, int] = (0, 0, 0)) -> File:
|
||||||
|
"""Stitch a cubemap into an equirectangular image
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files (List[File]): List of 6 files representing the 6 faces of the cubemap [back, right, front, left, up, down].
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StitchingError: If the stitching failed
|
||||||
|
ValueError: If the number of provided input files is not 6
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File: File object containing the stitched image
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(files) != 6:
|
||||||
|
raise ValueError("Exactly 6 files are required!")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
for i, file in enumerate(files):
|
||||||
|
with (Path(tempdir) / f"{self.CUBEMAP_ORDER[i]}.png").open("wb") as f:
|
||||||
|
f.write(file.file.read())
|
||||||
|
|
||||||
|
height = PIL.Image.open(files[0].file).height * 2
|
||||||
|
width = PIL.Image.open(files[0].file).width * 4
|
||||||
|
|
||||||
|
command = [
|
||||||
|
self.cube2sphere_path,
|
||||||
|
"front.png",
|
||||||
|
"back.png",
|
||||||
|
"right.png",
|
||||||
|
"left.png",
|
||||||
|
"up.png",
|
||||||
|
"down.png",
|
||||||
|
"-R", str(rotation[0]), str(rotation[1]), str(rotation[2]),
|
||||||
|
"-o", "out",
|
||||||
|
"-f", "png",
|
||||||
|
"-r", str(width), str(height),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(command, cwd=tempdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
self.logger.error(command)
|
||||||
|
self.logger.error(result.stderr.decode("utf-8"))
|
||||||
|
self.logger.error(result.stdout.decode("utf-8"))
|
||||||
|
self.logger.debug(tempdir)
|
||||||
|
time.sleep(600)
|
||||||
|
raise StitchingError(f"cube2sphere stitching failed for conversion {files[0].conversion.id}")
|
||||||
|
|
||||||
|
with (Path(tempdir) / "out0001.png").open("rb") as f:
|
||||||
|
result = File.objects.create(conversion=files[0].conversion, file=ContentFile(f.read(), name="result.png"))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
class PILStitcher(BaseStitcher):
|
||||||
|
"""Stitcher module using PIL to stitch images
|
||||||
|
"""
|
||||||
|
def cubemap_to_equirectangular(self, files: List[File], rotation: Tuple[int, int, int] = (0, 0, 0)) -> File:
|
||||||
|
'''Stitch a cubemap into an equirectangular image
|
||||||
|
|
||||||
|
This method does not use Blender, but instead uses PIL to stitch the images together.
|
||||||
|
This algorithm is not thoroughly tested and may not work correctly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files (List[File]): List of 6 files representing the 6 faces of the cubemap [back, right, front, left, up, down]
|
||||||
|
rotation (Tuple[int, int, int], optional): Not supported by this stitcher. Defaults to (0, 0, 0).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StitchingError: If the stitching failed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File: File object containing the stitched image
|
||||||
|
'''
|
||||||
|
|
||||||
|
if len(files) != 6:
|
||||||
|
raise ValueError("Exactly 6 files are required!")
|
||||||
|
|
||||||
|
back, right, front, left, top, bottom = [PIL.Image.open(f.file) for f in files]
|
||||||
|
|
||||||
|
dim = left.size[0]
|
||||||
|
|
||||||
|
raw = []
|
||||||
|
|
||||||
|
t_width = dim * 4
|
||||||
|
t_height = dim * 2
|
||||||
|
|
||||||
|
for y in range(t_height):
|
||||||
|
v = 1.0 - (float(y) / t_height)
|
||||||
|
phi = v * math.pi
|
||||||
|
|
||||||
|
for x in range(t_width):
|
||||||
|
u = float(x) / t_width
|
||||||
|
theta = u * math.pi * 2
|
||||||
|
|
||||||
|
x = math.cos(theta) * math.sin(phi)
|
||||||
|
y = math.sin(theta) * math.sin(phi)
|
||||||
|
z = math.cos(phi)
|
||||||
|
|
||||||
|
a = max(abs(x), abs(y), abs(z))
|
||||||
|
|
||||||
|
xx = x / a
|
||||||
|
yy = y / a
|
||||||
|
zz = z / a
|
||||||
|
|
||||||
|
if yy == -1:
|
||||||
|
currx = int(((-1 * math.tan(math.atan(x / y)) + 1.0) / 2.0) * dim)
|
||||||
|
ystore = int(((-1 * math.tan(math.atan(z / y)) + 1.0) / 2.0) * (dim - 1))
|
||||||
|
part = left
|
||||||
|
|
||||||
|
elif xx == 1:
|
||||||
|
currx = int(((math.tan(math.atan(y / x)) + 1.0) / 2.0) * dim)
|
||||||
|
ystore = int(((math.tan(math.atan(z / x)) + 1.0) / 2.0) * dim)
|
||||||
|
part = front
|
||||||
|
|
||||||
|
elif yy == 1:
|
||||||
|
currx = int(((-1 * math.tan(math.atan(x / y)) + 1.0) / 2.0) * dim)
|
||||||
|
ystore = int(((math.tan(math.atan(z / y)) + 1.0) / 2.0) * (dim - 1))
|
||||||
|
part = right
|
||||||
|
|
||||||
|
elif xx == -1:
|
||||||
|
currx = int(((math.tan(math.atan(y / x)) + 1.0) / 2.0) * dim)
|
||||||
|
ystore = int(((-1 * math.tan(math.atan(z / x)) + 1.0) / 2.0) * (dim - 1))
|
||||||
|
part = back
|
||||||
|
|
||||||
|
elif zz == 1:
|
||||||
|
currx = int(((math.tan(math.atan(y / z)) + 1.0) / 2.0) * dim)
|
||||||
|
ystore = int(((-1 * math.tan(math.atan(x / z)) + 1.0) / 2.0) * (dim - 1))
|
||||||
|
part = bottom
|
||||||
|
|
||||||
|
else:
|
||||||
|
currx = int(((-1 * math.tan(math.atan(y / z)) + 1.0) / 2.0) * dim)
|
||||||
|
ystore = int(((-1 * math.tan(math.atan(x / z)) + 1.0) / 2.0) * (dim - 1))
|
||||||
|
part = top
|
||||||
|
|
||||||
|
curry = (dim - 1) if ystore > (dim - 1) else ystore
|
||||||
|
|
||||||
|
if curry > (dim - 1):
|
||||||
|
curry = dim - 1
|
||||||
|
|
||||||
|
if currx > (dim - 1):
|
||||||
|
currx = dim - 1
|
||||||
|
|
||||||
|
raw.append(part.getpixel((currx, curry)))
|
||||||
|
|
||||||
|
bio = io.BytesIO()
|
||||||
|
PIL.Image.frombytes("RGB", (t_width, t_height), bytes(raw)).save(bio, "PNG")
|
||||||
|
bio.seek(0)
|
||||||
|
|
||||||
|
result = File.objects.create(conversion=files[0].conversion, file=ContentFile(output, name="result.png"))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def stitch(self, files: List[List[File]]) -> File:
|
||||||
|
"""Stitch a list of images together
|
||||||
|
|
||||||
|
The input is a list of lists of images.
|
||||||
|
Each list of images is stitched into one line horizontally.
|
||||||
|
The resulting lines are then stitched together vertically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files (List[List[File]]): List of lists of files to stitch together
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StitchingError: If the stitching failed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File: File object containing the stitched image
|
||||||
|
"""
|
||||||
|
if len(files) == 0:
|
||||||
|
raise StitchingError("No files to stitch!")
|
||||||
|
|
||||||
|
if len(files[0]) == 0:
|
||||||
|
raise StitchingError("No files to stitch!")
|
||||||
|
|
||||||
|
image_files = [[PIL.Image.open(f.file) for f in line] for line in files]
|
||||||
|
|
||||||
|
width = image_files[0][0].width
|
||||||
|
height = image_files[0][0].height
|
||||||
|
|
||||||
|
for line in image_files:
|
||||||
|
if len(line) != len(files[0]):
|
||||||
|
raise ValueError("All lines must have the same length!")
|
||||||
|
|
||||||
|
for file in line:
|
||||||
|
if file.width != width or file.height != height:
|
||||||
|
raise ValueError("All files must have the same dimensions!")
|
||||||
|
|
||||||
|
result = PIL.Image.new("RGB", (width * len(files[0]), height * len(files)))
|
||||||
|
|
||||||
|
for y, line in enumerate(image_files):
|
||||||
|
for x, file in enumerate(line):
|
||||||
|
result.paste(file, (x * width, y * height))
|
||||||
|
|
||||||
|
bio = io.BytesIO()
|
||||||
|
result.save(bio, "PNG")
|
||||||
|
bio.seek(0)
|
||||||
|
|
||||||
|
result_file = File.objects.create(conversion=files[0][0].conversion, file=ContentFile(bio.read(), name="result.png"))
|
||||||
|
return result_file
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CUBEMAP_TO_EQUIRECTANGULAR_STITCHER = BlenderStitcher
|
||||||
|
DEFAULT_STITCHER = PILStitcher
|
66
src/pix360core/loader.py
Normal file
66
src/pix360core/loader.py
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
|
from pix360core.classes.modules import DownloaderModule
|
||||||
|
|
||||||
|
import importlib.metadata
|
||||||
|
|
||||||
|
class Loader:
|
||||||
|
def __init__(self):
|
||||||
|
self.downloaders = self.__class__.load_downloaders()
|
||||||
|
|
||||||
|
def resolve_downloader_identifier(self, identifier: str) -> Optional[DownloaderModule]:
|
||||||
|
"""A function to resolve a downloader identifier to a downloader name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier (str): The downloader identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The downloader name
|
||||||
|
"""
|
||||||
|
|
||||||
|
for downloader in self.downloaders:
|
||||||
|
if downloader.identifier == identifier:
|
||||||
|
return downloader
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_downloader(self, url: str) -> List[Tuple[DownloaderModule, int]]:
|
||||||
|
"""A function to find the downloader(s) that can handle a given URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): The URL to test
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Tuple[DownloaderModule, int]]: A list of tuples containing the downloader and the certainty level
|
||||||
|
"""
|
||||||
|
|
||||||
|
downloaders = []
|
||||||
|
|
||||||
|
for downloader in self.downloaders:
|
||||||
|
downloader = downloader()
|
||||||
|
try:
|
||||||
|
certainty = downloader.test_url(url)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error while testing URL with {downloader.identifier}: {e}") from e
|
||||||
|
if certainty != DownloaderModule.CERTAINTY_UNSUPPORTED:
|
||||||
|
downloaders.append((downloader, certainty))
|
||||||
|
|
||||||
|
return downloaders
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_downloaders() -> List:
|
||||||
|
"""A function to find all downloaders installed, implementing the
|
||||||
|
pix360downloader entry point.
|
||||||
|
|
||||||
|
Returns: List of imported installed downloaders
|
||||||
|
"""
|
||||||
|
|
||||||
|
downloaders = []
|
||||||
|
|
||||||
|
for entry_point in importlib.metadata.entry_points().get("pix360downloader", []):
|
||||||
|
try:
|
||||||
|
downloaders.append(entry_point.load())
|
||||||
|
except:
|
||||||
|
print(f"Something went wrong trying to import {entry_point}")
|
||||||
|
|
||||||
|
return downloaders
|
0
src/pix360core/management/__init__.py
Normal file
0
src/pix360core/management/__init__.py
Normal file
0
src/pix360core/management/commands/__init__.py
Normal file
0
src/pix360core/management/commands/__init__.py
Normal file
15
src/pix360core/management/commands/runworker.py
Normal file
15
src/pix360core/management/commands/runworker.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from pix360core.models import Conversion, ConversionStatus
|
||||||
|
from pix360core.worker import Worker
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Run the worker'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""Handle the command
|
||||||
|
"""
|
||||||
|
worker = Worker()
|
||||||
|
worker.run()
|
1
src/pix360core/managers/__init__.py
Normal file
1
src/pix360core/managers/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .auth import UserManager
|
23
src/pix360core/managers/auth.py
Normal file
23
src/pix360core/managers/auth.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from django.contrib.auth.base_user import BaseUserManager
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager(BaseUserManager):
|
||||||
|
def create_user(self, email, password, **extra_fields):
|
||||||
|
if not email:
|
||||||
|
raise ValueError('Email must be set')
|
||||||
|
email = self.normalize_email(email)
|
||||||
|
user = self.model(email=email, **extra_fields)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_superuser(self, email, password, **extra_fields):
|
||||||
|
extra_fields.setdefault('is_staff', True)
|
||||||
|
extra_fields.setdefault('is_superuser', True)
|
||||||
|
extra_fields.setdefault('is_active', True)
|
||||||
|
|
||||||
|
if extra_fields.get('is_staff') is not True:
|
||||||
|
raise ValueError('Superuser must have is_staff=True.')
|
||||||
|
if extra_fields.get('is_superuser') is not True:
|
||||||
|
raise ValueError('Superuser must have is_superuser=True.')
|
||||||
|
return self.create_user(email, password, **extra_fields)
|
72
src/pix360core/migrations/0001_initial.py
Normal file
72
src/pix360core/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-20 14:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="User",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||||
|
(
|
||||||
|
"last_login",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, null=True, verbose_name="last login"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_superuser",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||||
|
verbose_name="superuser status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("email", models.EmailField(max_length=254, unique=True)),
|
||||||
|
("is_staff", models.BooleanField(default=False)),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
|
("date_joined", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"groups",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_permissions",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.permission",
|
||||||
|
verbose_name="user permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
79
src/pix360core/migrations/0002_conversion_file.py
Normal file
79
src/pix360core/migrations/0002_conversion_file.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-23 08:37
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import pix360core.models.content
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pix360core", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Conversion",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4, primary_key=True, serialize=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("url", models.URLField()),
|
||||||
|
("downloader", models.CharField(blank=True, max_length=256, null=True)),
|
||||||
|
("properties", models.JSONField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.IntegerField(
|
||||||
|
choices=[
|
||||||
|
(0, "Pending"),
|
||||||
|
(1, "Processing"),
|
||||||
|
(2, "Done"),
|
||||||
|
(-1, "Failed"),
|
||||||
|
],
|
||||||
|
default=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("log", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="File",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4, primary_key=True, serialize=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file",
|
||||||
|
models.FileField(
|
||||||
|
upload_to=pix360core.models.content.file_upload_path
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_result", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"conversion",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="pix360core.conversion",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
17
src/pix360core/migrations/0003_file_mime_type.py
Normal file
17
src/pix360core/migrations/0003_file_mime_type.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-23 09:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pix360core", "0002_conversion_file"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="file",
|
||||||
|
name="mime_type",
|
||||||
|
field=models.CharField(default="application/octet-stream", max_length=256),
|
||||||
|
),
|
||||||
|
]
|
17
src/pix360core/migrations/0004_conversion_title.py
Normal file
17
src/pix360core/migrations/0004_conversion_title.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-23 12:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("pix360core", "0003_file_mime_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="conversion",
|
||||||
|
name="title",
|
||||||
|
field=models.CharField(blank=True, max_length=256, null=True),
|
||||||
|
),
|
||||||
|
]
|
0
src/pix360core/migrations/__init__.py
Normal file
0
src/pix360core/migrations/__init__.py
Normal file
2
src/pix360core/models/__init__.py
Normal file
2
src/pix360core/models/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from .auth import User
|
||||||
|
from .content import File, Conversion, ConversionStatus
|
20
src/pix360core/models/auth.py
Normal file
20
src/pix360core/models/auth.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ..managers import UserManager
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractBaseUser, PermissionsMixin):
|
||||||
|
email = models.EmailField(unique=True)
|
||||||
|
is_staff = models.BooleanField(default=False)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
date_joined = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
USERNAME_FIELD = 'email'
|
||||||
|
REQUIRED_FIELDS = []
|
||||||
|
|
||||||
|
objects = UserManager()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.email
|
85
src/pix360core/models/content.py
Normal file
85
src/pix360core/models/content.py
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
def file_upload_path(instance, filename) -> str:
|
||||||
|
"""Generate upload path for a File object
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance (File): File object to generate path for
|
||||||
|
filename (str): Original filename of the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Upload path for the file
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"content/{instance.conversion.id}/{instance.id}/{filename}"
|
||||||
|
|
||||||
|
class File(models.Model):
|
||||||
|
"""Model for files downloaded or generated by PIX360
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id (UUIDField): UUID of the file
|
||||||
|
file (FileField): File object containing the file
|
||||||
|
conversion (ForeignKey): Conversion object that this file belongs to
|
||||||
|
is_result (BooleanField): Whether this file is the result of a conversion
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
|
||||||
|
file = models.FileField(upload_to=file_upload_path)
|
||||||
|
mime_type = models.CharField(max_length=256, default="application/octet-stream")
|
||||||
|
conversion = models.ForeignKey(to='Conversion', on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
is_result = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class ConversionStatus(models.IntegerChoices):
|
||||||
|
"""Enum for conversion statuses
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
PENDING (int): Conversion is pending
|
||||||
|
PROCESSING (int): Conversion is processing
|
||||||
|
DONE (int): Conversion is done
|
||||||
|
FAILED (int): Conversion has failed
|
||||||
|
"""
|
||||||
|
|
||||||
|
PENDING = 0
|
||||||
|
PROCESSING = 1
|
||||||
|
DONE = 2
|
||||||
|
FAILED = -1
|
||||||
|
|
||||||
|
DOWNLOADING = 10
|
||||||
|
STITCHING = 11
|
||||||
|
|
||||||
|
class Conversion(models.Model):
|
||||||
|
"""Model for conversions performed by PIX360
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id (UUIDField): UUID of the conversion
|
||||||
|
url (URLField): URL of the content to convert
|
||||||
|
downloader (CharField): Downloader module used to download the content
|
||||||
|
user (ForeignKey): User who requested the conversion
|
||||||
|
properties (JSONField): Properties of the conversion
|
||||||
|
status (IntegerField): Status of the conversion (see ConversionStatus)
|
||||||
|
log (TextField): Log of the conversion
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
|
||||||
|
title = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
url = models.URLField()
|
||||||
|
downloader = models.CharField(max_length=256, null=True, blank=True)
|
||||||
|
user = models.ForeignKey(to=get_user_model(), on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
properties = models.JSONField(null=True, blank=True)
|
||||||
|
status = models.IntegerField(choices=ConversionStatus.choices, default=ConversionStatus.PENDING)
|
||||||
|
log = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def result(self) -> File:
|
||||||
|
"""Get the result file of this conversion
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File: Result file of this conversion
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
File.DoesNotExist: If no result file exists
|
||||||
|
"""
|
||||||
|
return File.objects.get(conversion=self, is_result=True)
|
6455
src/pix360core/static/dist/css/theme.css
vendored
Normal file
6455
src/pix360core/static/dist/css/theme.css
vendored
Normal file
File diff suppressed because it is too large
Load diff
7
src/pix360core/static/dist/js/bootstrap.min.js
vendored
Normal file
7
src/pix360core/static/dist/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
src/pix360core/static/dist/js/jquery-3.3.1.min.js
vendored
Normal file
2
src/pix360core/static/dist/js/jquery-3.3.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
src/pix360core/static/img/favicon.png
Normal file
BIN
src/pix360core/static/img/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 94 KiB |
BIN
src/pix360core/static/img/spinner.gif
Normal file
BIN
src/pix360core/static/img/spinner.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 114 KiB |
151
src/pix360core/static/js/worker.js
Normal file
151
src/pix360core/static/js/worker.js
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
$("#options").hide();
|
||||||
|
|
||||||
|
$body = $("body");
|
||||||
|
|
||||||
|
function toggleOptions() {
|
||||||
|
$("#options").toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockform() {
|
||||||
|
$("#theform :input").prop("disabled", true);
|
||||||
|
$body.addClass("loading");
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockform() {
|
||||||
|
$("#theform :input").prop("disabled", false);
|
||||||
|
$body.removeClass("loading");
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletecard(jobid) {
|
||||||
|
if ($("#" + jobid).length) {
|
||||||
|
$("#" + jobid).remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addcard(jobid, title) {
|
||||||
|
var text =
|
||||||
|
'<div class="col-sm-3" id="' +
|
||||||
|
jobid +
|
||||||
|
'"> <div class="card"> <img class="card-img-top img-fluid" src="/static/img/spinner.gif" alt="Creating Image"><div style="text-align: center; font-weight: bold;" class="card-block">' +
|
||||||
|
title +
|
||||||
|
"</div> </div> </div>";
|
||||||
|
$("#cards").append(text);
|
||||||
|
$("html,body").animate({ scrollTop: $("#" + jobid).offset().top });
|
||||||
|
}
|
||||||
|
|
||||||
|
function failcard(jobid, title) {
|
||||||
|
Notification.requestPermission(function (permission) {
|
||||||
|
if (permission === "granted") {
|
||||||
|
var notification = new Notification("PIX360", {
|
||||||
|
body: title + ": Export failed.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var text =
|
||||||
|
'<div class="card"> <div style="text-align: center; color: red; font-weight: bold;" class="card-block">' +
|
||||||
|
title +
|
||||||
|
': Export failed.</div><div style="text-align: center;" class="card-block"> <a style="color: white;" onclick="deletecard(\'' +
|
||||||
|
jobid +
|
||||||
|
'\');" class="btn btn-danger">Hide</a></div> </div>';
|
||||||
|
$("#" + jobid).html(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishcard(jobid, title, video) {
|
||||||
|
Notification.requestPermission(function (permission) {
|
||||||
|
if (permission === "granted") {
|
||||||
|
var notification = new Notification("PIX360", {
|
||||||
|
body: title + ": Export finished.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var text =
|
||||||
|
'<div class="card"> <img ' +
|
||||||
|
(video ? 'id="' + jobid + '-thumb"' : "") +
|
||||||
|
' class="card-img-top img-fluid" src="/getjob/' +
|
||||||
|
jobid +
|
||||||
|
(video ? "-thumb" : "") +
|
||||||
|
'" alt="Final ' +
|
||||||
|
(video ? "Video" : "Image") +
|
||||||
|
'"><div style="text-align: center; font-weight: bold;" class="card-block">' +
|
||||||
|
title +
|
||||||
|
'</div> <div style="text-align: center; color: white;" class="card-block"> <a href="/getjob/' +
|
||||||
|
jobid +
|
||||||
|
'" class="btn btn-primary">Download</a> <a onclick="deletecard(\'' +
|
||||||
|
jobid +
|
||||||
|
'\');" class="btn btn-danger">Hide</a></div> </div>';
|
||||||
|
$("#" + jobid).html(text);
|
||||||
|
|
||||||
|
var counter = 0;
|
||||||
|
var interval = setInterval(function () {
|
||||||
|
var image = document.getElementById(jobid + "-thumb");
|
||||||
|
image.src = "/getjob/" + jobid + "-thumb?rand=" + Math.random();
|
||||||
|
if (++counter === 10) {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#theform").submit(function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.checkValidity()) {
|
||||||
|
$.ajax({
|
||||||
|
type: "POST",
|
||||||
|
url: "/start",
|
||||||
|
data: $("#theform").serialize(),
|
||||||
|
success: function (msg) {
|
||||||
|
var title = $("#title").val() ? $("#title").val() : "No title";
|
||||||
|
var interval = setInterval(checkServerForFile, 3000, msg.id, title);
|
||||||
|
window.panaxworking = false;
|
||||||
|
addcard(msg.id, title);
|
||||||
|
|
||||||
|
function checkServerForFile(jobid, title) {
|
||||||
|
if (!window.panaxworking) {
|
||||||
|
window.panaxworking = true;
|
||||||
|
$.ajax({
|
||||||
|
type: "GET",
|
||||||
|
cache: false,
|
||||||
|
url: "/status/" + jobid,
|
||||||
|
statusCode: {
|
||||||
|
403: function () {
|
||||||
|
window.location.href = "/";
|
||||||
|
},
|
||||||
|
404: function () {
|
||||||
|
clearInterval(interval);
|
||||||
|
failcard(jobid, title);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
200: function (data, tstatus, xhr) {
|
||||||
|
if (data.status == "finished") {
|
||||||
|
clearInterval(interval);
|
||||||
|
finishcard(
|
||||||
|
jobid,
|
||||||
|
title,
|
||||||
|
data.content_type == "video/mp4"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else if (data.status == "failed") {
|
||||||
|
clearInterval(interval);
|
||||||
|
failcard(jobid, title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
500: function () {
|
||||||
|
clearInterval(interval);
|
||||||
|
failcard(jobid, title);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.panaxworking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
if (Notification.permission !== "granted") {
|
||||||
|
Notification.requestPermission();
|
||||||
|
}
|
||||||
|
});
|
64
src/pix360core/templates/pix360core/converter.html
Normal file
64
src/pix360core/templates/pix360core/converter.html
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="{% static "dist/css/theme.css" %}" type="text/css">
|
||||||
|
<link rel="icon" type="image/png" href="{% static "img/favicon.png" %}">
|
||||||
|
<title>Panorama Image Export</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h1 class="">Panorama Image Export</h1>
|
||||||
|
<h2 class=""><a href="https://kumi.systems/">by Kumi Systems</a></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<form class="" id="theform">
|
||||||
|
<div class="form-group"> <label>URL</label> <input type="text" class="form-control" placeholder="https://example.com/0/0/0_0.jpg" name="url" required="required">
|
||||||
|
<div class="form-group"> <label>Title</label> <input type="" class="form-control" placeholder="1234 - Shiny Place" id="title" name="title"> </div>
|
||||||
|
<div class="form-group"> <label style="display: block;">Resolution</label> <input type="" class="form-control" placeholder="3840" name="width" style="width: 100px; display: inline;"> x <input type="" class="form-control" placeholder="1920" name="height" style="width: 100px; display: inline;"> </div>
|
||||||
|
<div id="options">
|
||||||
|
<div class="form-group"> <label style="display: block;">Rotation on X/Y/Z axes</label> <input type="" class="form-control" placeholder="0" name="rx" style="width: 100px; display: inline;"> / <input type="" class="form-control" placeholder="0" name="ry" style="width: 100px; display: inline;"> / <input type="" class="form-control" placeholder="0" name="rz" style="width: 100px; display: inline;"> </div>
|
||||||
|
<div class="form-group"> <label>Transposition<br></label><select class="custom-control custom-select" name="transpose">
|
||||||
|
<option value="1" selected="True">Default: Flip left-right (mirror)</option>
|
||||||
|
<option value="0">No transposition</option>
|
||||||
|
</select> </div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success">Submit</button> <button type="reset" class="btn btn-danger">Reset</button> <button type="button" class="btn btn-info" onclick="toggleOptions()">More options</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row" id="cards"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal"></div>
|
||||||
|
|
||||||
|
<script src="{% static "dist/js/jquery-3.3.1.min.js" %}"></script>
|
||||||
|
<script src="{% static "dist/js/bootstrap.min.js" %}"></script>
|
||||||
|
<script src="{% static "js/worker.js" %}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
3
src/pix360core/tests.py
Normal file
3
src/pix360core/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
13
src/pix360core/urls.py
Normal file
13
src/pix360core/urls.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import ConverterView, StartConversionView, ConversionStatusView, ConversionLogView, ConversionListView, ConversionDeleteView, ConversionResultView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', ConverterView.as_view(), name='converter'),
|
||||||
|
path('start', StartConversionView.as_view(), name='conversion_start'),
|
||||||
|
path('status/<uuid:id>', ConversionStatusView.as_view(), name='conversion_status'),
|
||||||
|
path('log/<uuid:id>', ConversionLogView.as_view(), name='conversion_log'),
|
||||||
|
path('list', ConversionListView.as_view(), name='conversion_list'),
|
||||||
|
path('delete/<uuid:id>', ConversionDeleteView.as_view(), name='conversion_delete'),
|
||||||
|
path('result/<uuid:id>', ConversionResultView.as_view(), name='conversion_result'),
|
||||||
|
]
|
133
src/pix360core/views.py
Normal file
133
src/pix360core/views.py
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
from django.views.generic import View, TemplateView
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.http import JsonResponse, HttpResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
|
||||||
|
from pix360core.models import Conversion, ConversionStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ConverterView(LoginRequiredMixin, TemplateView):
|
||||||
|
"""View for the converter
|
||||||
|
"""
|
||||||
|
template_name = 'pix360core/converter.html'
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class StartConversionView(LoginRequiredMixin, View):
|
||||||
|
"""View for starting a conversion
|
||||||
|
"""
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Handle the POST request
|
||||||
|
"""
|
||||||
|
url = request.POST.get('url')
|
||||||
|
title = request.POST.get('title')
|
||||||
|
if not url:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'No URL provided'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
conversion = Conversion.objects.create(url=url, title=title, user=request.user)
|
||||||
|
return JsonResponse({
|
||||||
|
'id': conversion.id
|
||||||
|
})
|
||||||
|
|
||||||
|
class ConversionStatusView(LoginRequiredMixin, View):
|
||||||
|
"""View for getting the status of a conversion
|
||||||
|
"""
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Handle the GET request
|
||||||
|
"""
|
||||||
|
conversion = Conversion.objects.filter(id=kwargs['id']).first()
|
||||||
|
if not conversion or not (conversion.user == request.user):
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Conversion not found'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
response = {}
|
||||||
|
|
||||||
|
if conversion.status == ConversionStatus.DONE:
|
||||||
|
response['status'] = "completed"
|
||||||
|
response['result'] = conversion.result.file.path
|
||||||
|
response['content_type'] = conversion.result.mime_type
|
||||||
|
elif conversion.status == ConversionStatus.FAILED:
|
||||||
|
response['status'] = "failed"
|
||||||
|
elif conversion.status == ConversionStatus.DOWNLOADING:
|
||||||
|
response['status'] = "downloading"
|
||||||
|
elif conversion.status == ConversionStatus.STITCHING:
|
||||||
|
response['status'] = "stitching"
|
||||||
|
else:
|
||||||
|
response['status'] = "processing"
|
||||||
|
|
||||||
|
return JsonResponse(response)
|
||||||
|
|
||||||
|
class ConversionLogView(LoginRequiredMixin, View):
|
||||||
|
"""View for getting the log of a conversion
|
||||||
|
"""
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Handle the GET request
|
||||||
|
"""
|
||||||
|
conversion = Conversion.objects.filter(id=kwargs['id']).first()
|
||||||
|
if not conversion or not (conversion.user == request.user):
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Conversion not found'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'log': conversion.log
|
||||||
|
})
|
||||||
|
|
||||||
|
class ConversionResultView(LoginRequiredMixin, View):
|
||||||
|
"""View for getting the result of a conversion
|
||||||
|
"""
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Handle the GET request
|
||||||
|
"""
|
||||||
|
conversion = Conversion.objects.filter(id=kwargs['id']).first()
|
||||||
|
if not conversion or not (conversion.user == request.user):
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Conversion not found'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
file = conversion.result
|
||||||
|
if not file:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Conversion not done'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
content = file.file.read()
|
||||||
|
|
||||||
|
response = HttpResponse(content, content_type=file.mime_type)
|
||||||
|
|
||||||
|
class ConversionListView(LoginRequiredMixin, View):
|
||||||
|
"""View for getting the list of conversions
|
||||||
|
"""
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Handle the GET request
|
||||||
|
"""
|
||||||
|
conversions = Conversion.objects.filter(user=request.user)
|
||||||
|
return JsonResponse({
|
||||||
|
'conversions': [{
|
||||||
|
'id': conversion.id,
|
||||||
|
'url': conversion.url,
|
||||||
|
'title': conversion.title,
|
||||||
|
'status': conversion.status,
|
||||||
|
} for conversion in conversions]
|
||||||
|
})
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class ConversionDeleteView(LoginRequiredMixin, View):
|
||||||
|
"""View for deleting a conversion
|
||||||
|
"""
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Handle the POST request
|
||||||
|
"""
|
||||||
|
conversion = Conversion.objects.filter(id=kwargs['id']).first()
|
||||||
|
if not conversion or not (conversion.user == request.user):
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Conversion not found'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
conversion.user = None
|
||||||
|
conversion.save()
|
||||||
|
|
||||||
|
return JsonResponse({})
|
94
src/pix360core/worker.py
Normal file
94
src/pix360core/worker.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
from .loader import Loader
|
||||||
|
from .models import Conversion, File, ConversionStatus
|
||||||
|
from .classes import ConversionError
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
class Worker(multiprocessing.Process):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.loader = Loader()
|
||||||
|
self.logger = logging.getLogger("pix360")
|
||||||
|
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
||||||
|
self.logger.addHandler(handler)
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
self.logger.setLevel(logging.DEBUG)
|
||||||
|
else:
|
||||||
|
self.logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
def process_conversion(self, conversion: Conversion) -> File:
|
||||||
|
"""Process a conversion
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversion (Conversion): Conversion to process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File: Result of the conversion
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConversionError: If the conversion is invalid
|
||||||
|
DownloadError: If the download fails
|
||||||
|
StitchingError: If the stitching fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
if conversion.downloader:
|
||||||
|
downloader = self.loader.resolve_downloader_identifier(conversion.downloader)
|
||||||
|
if not downloader:
|
||||||
|
raise ConversionError("Downloader not found")
|
||||||
|
else:
|
||||||
|
downloaders = self.loader.find_downloader(conversion.url)
|
||||||
|
if len(downloaders) > 0:
|
||||||
|
downloaders.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
downloader = downloaders[0][0]
|
||||||
|
conversion.downloader = downloader.identifier
|
||||||
|
conversion.save()
|
||||||
|
else:
|
||||||
|
raise ConversionError("No downloader found")
|
||||||
|
|
||||||
|
result = downloader.process_conversion(conversion)
|
||||||
|
result.conversion = conversion
|
||||||
|
result.is_result = True
|
||||||
|
result.save()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run the worker
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
conversion = Conversion.objects.filter(status=ConversionStatus.PENDING).first()
|
||||||
|
|
||||||
|
if conversion:
|
||||||
|
conversion.status = ConversionStatus.PROCESSING
|
||||||
|
conversion.save()
|
||||||
|
self.logger.info(f"Processing conversion {conversion.id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.process_conversion(conversion)
|
||||||
|
result.is_result = True
|
||||||
|
result.save()
|
||||||
|
conversion.status = ConversionStatus.DONE
|
||||||
|
conversion.save()
|
||||||
|
self.logger.info(f"Conversion {conversion.id} done")
|
||||||
|
except Exception as e:
|
||||||
|
conversion.status = ConversionStatus.FAILED
|
||||||
|
conversion.log = traceback.format_exc()
|
||||||
|
conversion.save()
|
||||||
|
self.logger.error(f"Conversion {conversion.id} failed: {e}")
|
||||||
|
self.logger.debug(traceback.format_exc())
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.debug("No conversion to process")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Worker error: {e}")
|
Loading…
Reference in a new issue