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