188 lines
5.9 KiB
Python
188 lines
5.9 KiB
Python
import io
|
|
import logging
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from django.core.files.base import ContentFile
|
|
from PIL import Image, ImageOps
|
|
|
|
from tasks.models import TaskAttachment
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TASK_COVER_THUMBNAIL_SIZE = (640, 280)
|
|
IMAGE_EXTENSIONS = {
|
|
".avif",
|
|
".bmp",
|
|
".gif",
|
|
".heic",
|
|
".heif",
|
|
".jpeg",
|
|
".jpg",
|
|
".png",
|
|
".webp",
|
|
}
|
|
VIDEO_EXTENSIONS = {
|
|
".3gp",
|
|
".avi",
|
|
".m4v",
|
|
".mkv",
|
|
".mov",
|
|
".mp4",
|
|
".mpeg",
|
|
".mpg",
|
|
".ogv",
|
|
".webm",
|
|
}
|
|
|
|
|
|
def attachment_media_kind(attachment) -> str | None:
|
|
mime_type = (attachment.mime_type or "").lower()
|
|
suffix = Path(attachment.file_name or "").suffix.lower()
|
|
if mime_type.startswith("image/") or suffix in IMAGE_EXTENSIONS:
|
|
return "image"
|
|
if mime_type.startswith("video/") or suffix in VIDEO_EXTENSIONS:
|
|
return "video"
|
|
return None
|
|
|
|
|
|
def task_attachment_media_kind(attachment: TaskAttachment) -> str | None:
|
|
return attachment_media_kind(attachment)
|
|
|
|
|
|
def _render_image_thumbnail(attachment: TaskAttachment) -> bytes:
|
|
with attachment.file.open("rb") as source:
|
|
image = Image.open(source)
|
|
image.seek(0)
|
|
image = ImageOps.exif_transpose(image)
|
|
if image.mode not in ("RGB", "RGBA"):
|
|
image = image.convert("RGBA" if "transparency" in image.info else "RGB")
|
|
if image.mode == "RGBA":
|
|
background = Image.new("RGB", image.size, (20, 27, 36))
|
|
background.paste(image, mask=image.getchannel("A"))
|
|
image = background
|
|
image = ImageOps.fit(
|
|
image,
|
|
TASK_COVER_THUMBNAIL_SIZE,
|
|
method=Image.Resampling.LANCZOS,
|
|
)
|
|
output = io.BytesIO()
|
|
image.save(output, format="WEBP", quality=84, method=5)
|
|
return output.getvalue()
|
|
|
|
|
|
def _render_video_thumbnail(attachment: TaskAttachment) -> bytes:
|
|
ffmpeg = shutil.which("ffmpeg")
|
|
if not ffmpeg:
|
|
raise RuntimeError("ffmpeg is not installed")
|
|
|
|
input_suffix = Path(attachment.file_name or "").suffix or ".video"
|
|
input_path = ""
|
|
temporary_input = False
|
|
output_path = ""
|
|
try:
|
|
try:
|
|
input_path = attachment.file.path
|
|
except (AttributeError, NotImplementedError):
|
|
with tempfile.NamedTemporaryFile(
|
|
suffix=input_suffix, delete=False
|
|
) as source_file:
|
|
input_path = source_file.name
|
|
temporary_input = True
|
|
with attachment.file.open("rb") as source:
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
source_file.write(chunk)
|
|
with tempfile.NamedTemporaryFile(suffix=".webp", delete=False) as output_file:
|
|
output_path = output_file.name
|
|
|
|
filter_value = "scale=640:280:force_original_aspect_ratio=increase,crop=640:280"
|
|
command_prefix = [ffmpeg, "-hide_banner", "-loglevel", "error"]
|
|
command_suffix = [
|
|
"-map",
|
|
"0:v:0",
|
|
"-frames:v",
|
|
"1",
|
|
"-vf",
|
|
filter_value,
|
|
"-an",
|
|
"-sn",
|
|
"-dn",
|
|
"-c:v",
|
|
"libwebp",
|
|
"-quality",
|
|
"84",
|
|
"-y",
|
|
output_path,
|
|
]
|
|
commands = [
|
|
# Fast input seek works for ordinary MP4/WebM files.
|
|
[*command_prefix, "-ss", "0.5", "-i", input_path, *command_suffix],
|
|
# Accurate output seek handles fragmented MP4/MOV screen recordings
|
|
# whose first keyframe cannot be reached by input seeking.
|
|
[*command_prefix, "-i", input_path, "-ss", "0.5", *command_suffix],
|
|
# Some recordings start on a non-zero timeline. In that case use the
|
|
# first frame FFmpeg can decode instead of requiring a timestamp.
|
|
[*command_prefix, "-i", input_path, *command_suffix],
|
|
]
|
|
last_error = "FFmpeg did not produce a video thumbnail."
|
|
for command in commands:
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
check=False,
|
|
timeout=30,
|
|
)
|
|
except subprocess.TimeoutExpired as error:
|
|
last_error = str(error)
|
|
continue
|
|
if result.returncode == 0 and Path(output_path).stat().st_size > 0:
|
|
break
|
|
last_error = result.stderr.decode("utf-8", errors="replace")
|
|
else:
|
|
raise RuntimeError(last_error)
|
|
return Path(output_path).read_bytes()
|
|
finally:
|
|
if temporary_input and input_path:
|
|
Path(input_path).unlink(missing_ok=True)
|
|
if output_path:
|
|
Path(output_path).unlink(missing_ok=True)
|
|
|
|
|
|
def render_attachment_thumbnail(attachment) -> bytes:
|
|
media_kind = attachment_media_kind(attachment)
|
|
if media_kind == "image":
|
|
return _render_image_thumbnail(attachment)
|
|
if media_kind == "video":
|
|
return _render_video_thumbnail(attachment)
|
|
raise ValueError("Attachment is not supported media")
|
|
|
|
|
|
def ensure_task_attachment_thumbnail(attachment: TaskAttachment) -> bool:
|
|
if attachment.thumbnail:
|
|
return True
|
|
if not task_attachment_media_kind(attachment):
|
|
return False
|
|
|
|
try:
|
|
thumbnail = render_attachment_thumbnail(attachment)
|
|
attachment.thumbnail.save(
|
|
f"task-{attachment.task_id}-attachment-{attachment.id}.webp",
|
|
ContentFile(thumbnail),
|
|
save=True,
|
|
)
|
|
return True
|
|
except Exception as error:
|
|
logger.exception(
|
|
"Task attachment thumbnail generation failed",
|
|
extra={
|
|
"event": "task.attachment.thumbnail_generation_failed",
|
|
"task_id": attachment.task_id,
|
|
"attachment_id": attachment.id,
|
|
"error_type": type(error).__name__,
|
|
},
|
|
)
|
|
return False
|