Pix360 core app - current status

This commit is contained in:
Kumi 2023-10-26 09:51:39 +02:00
commit dd11adcced
Signed by: kumi
GPG key ID: ECBCC9082395383F
38 changed files with 7783 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
db.sqlite3
settings.ini
*.pyc
__pycache__/
venv/
.vscode/

0
LICENSE Normal file
View file

0
README.md Normal file
View file

26
pyproject.toml Normal file
View 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"

View file

10
src/pix360core/admin.py Normal file
View 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
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class Pix360CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "pix360core"

View 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')

View 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

View 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

View 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

View 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)!")

View 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
View 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

View file

View 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()

View file

@ -0,0 +1 @@
from .auth import UserManager

View 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)

View 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,
},
),
]

View 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",
),
),
],
),
]

View 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),
),
]

View 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),
),
]

View file

View file

@ -0,0 +1,2 @@
from .auth import User
from .content import File, Conversion, ConversionStatus

View 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

View 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

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View 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();
}
});

View 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
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

13
src/pix360core/urls.py Normal file
View 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
View 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
View 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}")