added video import and playback functionality

This commit is contained in:
Bryan Van Deusen
2025-08-03 20:42:49 -04:00
parent 3c3bfed75f
commit c480a176c2
5 changed files with 111 additions and 34 deletions
+40 -27
View File
@@ -1,14 +1,12 @@
// /app/static/js/modal-pagination.js // /app/static/js/modal-pagination.js
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('imageModal'); const modal = document.getElementById('imageModal');
const modalImage = document.getElementById('modalImage');
const modalPrev = document.getElementById('modalPrev'); const modalPrev = document.getElementById('modalPrev');
const modalNext = document.getElementById('modalNext'); const modalNext = document.getElementById('modalNext');
const modalClose = document.getElementById('modalClose'); const modalClose = document.getElementById('modalClose');
const modalImageWrapper = document.querySelector('.modal-image-wrapper'); const modalImageWrapper = document.querySelector('.modal-image-wrapper');
modalImage.setAttribute('draggable', 'false');
let images = Array.from(document.querySelectorAll('.img-clickable')); let images = Array.from(document.querySelectorAll('.img-clickable'));
let currentIndex = -1; let currentIndex = -1;
let isZoomed = false; let isZoomed = false;
@@ -25,7 +23,26 @@ document.addEventListener('DOMContentLoaded', () => {
function updateImage(index) { function updateImage(index) {
const img = images[index]; const img = images[index];
modalImage.src = img.dataset.full; const fileType = img.dataset.type || "image";
modalImageWrapper.innerHTML = "";
if (fileType === "video") {
const video = document.createElement("video");
video.src = img.dataset.full;
video.controls = true;
video.autoplay = true;
video.style.maxWidth = "100%";
video.style.maxHeight = "100%";
modalImageWrapper.appendChild(video);
} else {
const image = document.createElement("img");
image.src = img.dataset.full;
image.alt = "Full View";
image.setAttribute("draggable", "false");
image.id = "modalImage";
modalImageWrapper.appendChild(image);
}
currentIndex = index; currentIndex = index;
resetZoom(); resetZoom();
} }
@@ -38,7 +55,7 @@ document.addEventListener('DOMContentLoaded', () => {
function closeModal() { function closeModal() {
modal.classList.remove('active'); modal.classList.remove('active');
modalImage.src = ''; modalImageWrapper.innerHTML = '';
currentIndex = -1; currentIndex = -1;
resetZoom(); resetZoom();
history.replaceState({}, '', window.location.pathname + window.location.search); history.replaceState({}, '', window.location.pathname + window.location.search);
@@ -65,15 +82,10 @@ document.addEventListener('DOMContentLoaded', () => {
img.addEventListener('click', () => openModal(i)); img.addEventListener('click', () => openModal(i));
}); });
const newHasNext = doc.getElementById('hasNextPage')?.value || "false"; hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
const newNextUrl = doc.getElementById('nextPageUrl')?.value || ""; nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
const newHasPrev = doc.getElementById('hasPrevPage')?.value || "false"; hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
const newPrevUrl = doc.getElementById('prevPageUrl')?.value || ""; prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
hasNextPageInput.value = newHasNext;
nextPageUrlInput.value = newNextUrl;
hasPrevPageInput.value = newHasPrev;
prevPageUrlInput.value = newPrevUrl;
history.pushState({}, '', nextPageUrlInput.value); history.pushState({}, '', nextPageUrlInput.value);
openModal(0); openModal(0);
@@ -103,15 +115,10 @@ document.addEventListener('DOMContentLoaded', () => {
img.addEventListener('click', () => openModal(i)); img.addEventListener('click', () => openModal(i));
}); });
const newHasNext = doc.getElementById('hasNextPage')?.value || "false"; hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
const newNextUrl = doc.getElementById('nextPageUrl')?.value || ""; nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
const newHasPrev = doc.getElementById('hasPrevPage')?.value || "false"; hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
const newPrevUrl = doc.getElementById('prevPageUrl')?.value || ""; prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
hasNextPageInput.value = newHasNext;
nextPageUrlInput.value = newNextUrl;
hasPrevPageInput.value = newHasPrev;
prevPageUrlInput.value = newPrevUrl;
history.pushState({}, '', prevPageUrlInput.value); history.pushState({}, '', prevPageUrlInput.value);
openModal(images.length - 1); openModal(images.length - 1);
@@ -121,6 +128,8 @@ document.addEventListener('DOMContentLoaded', () => {
} }
function toggleZoom() { function toggleZoom() {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isImage) return;
isZoomed = !isZoomed; isZoomed = !isZoomed;
modalImageWrapper.classList.toggle('zoomed', isZoomed); modalImageWrapper.classList.toggle('zoomed', isZoomed);
modalImageWrapper.style.cursor = isZoomed ? 'grab' : ''; modalImageWrapper.style.cursor = isZoomed ? 'grab' : '';
@@ -139,7 +148,8 @@ document.addEventListener('DOMContentLoaded', () => {
} }
modalImageWrapper.addEventListener('mousedown', (e) => { modalImageWrapper.addEventListener('mousedown', (e) => {
if (!isZoomed) return; const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isZoomed || !isImage) return;
isDragging = true; isDragging = true;
dragMoved = false; dragMoved = false;
modalImageWrapper.style.cursor = 'grabbing'; modalImageWrapper.style.cursor = 'grabbing';
@@ -153,16 +163,19 @@ document.addEventListener('DOMContentLoaded', () => {
modalImageWrapper.addEventListener('mouseleave', () => { modalImageWrapper.addEventListener('mouseleave', () => {
isDragging = false; isDragging = false;
if (isZoomed) modalImageWrapper.style.cursor = 'grab'; const isImage = modalImageWrapper.querySelector('img') !== null;
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
}); });
modalImageWrapper.addEventListener('mouseup', () => { modalImageWrapper.addEventListener('mouseup', () => {
isDragging = false; isDragging = false;
if (isZoomed) modalImageWrapper.style.cursor = 'grab'; const isImage = modalImageWrapper.querySelector('img') !== null;
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
}); });
modalImageWrapper.addEventListener('mousemove', (e) => { modalImageWrapper.addEventListener('mousemove', (e) => {
if (!isDragging || !isZoomed) return; const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isDragging || !isZoomed || !isImage) return;
e.preventDefault(); e.preventDefault();
dragMoved = true; dragMoved = true;
const x = e.pageX - modalImageWrapper.offsetLeft; const x = e.pageX - modalImageWrapper.offsetLeft;
+13
View File
@@ -114,6 +114,7 @@ body {
} }
.gallery-thumb { .gallery-thumb {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-size: cover; background-size: cover;
@@ -135,6 +136,18 @@ body {
color: #d4d4d4; color: #d4d4d4;
} }
.play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3rem;
color: white;
text-shadow: 0 0 8px rgba(0, 0, 0, 0.8);
opacity: 1;
pointer-events: none;
z-index: 2;
}
.pagination-container { .pagination-container {
text-align: center; text-align: center;
+8 -3
View File
@@ -3,10 +3,15 @@
{% for image in images.items %} {% for image in images.items %}
<div class="gallery-item"> <div class="gallery-item">
<div class="gallery-thumb img-clickable" <div class="gallery-thumb img-clickable"
data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}" data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}"
style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');" data-type="{{ 'video' if image.filename.lower().endswith(('.mp4', '.mov')) else 'image' }}"
data-filename="{{ image.filename }}"> style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');"
data-filename="{{ image.filename }}">
{% if image.filename.lower().endswith(('.mp4', '.mov')) %}
<div class="play-overlay"></div>
{% endif %}
</div> </div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a> <a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<span class="image-date">{{ image.taken_at or image.imported_at | datetimeformat }}</span> <span class="image-date">{{ image.taken_at or image.imported_at | datetimeformat }}</span>
</div> </div>
+49 -3
View File
@@ -1,5 +1,4 @@
# app/utils/image_importer.py # app/utils/image_importer.py
from datetime import datetime from datetime import datetime
import os, shutil, hashlib, time import os, shutil, hashlib, time
from PIL import Image from PIL import Image
@@ -7,6 +6,7 @@ import exifread
import mimetypes import mimetypes
import imagehash import imagehash
import uuid import uuid
import subprocess
from app import db from app import db
from app.models import ImageRecord from app.models import ImageRecord
@@ -29,7 +29,7 @@ def import_images_task(source_dir, dest_dir):
for root, _, files in os.walk(artist_path): for root, _, files in os.walk(artist_path):
for file in files: for file in files:
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff')): if file.lower().endswith(('.jpg', '.jpeg', '.jfif', '.png', '.gif', '.bmp', '.tiff', '.webp', '.mp4', '.mov')):
full_path = os.path.join(root, file) full_path = os.path.join(root, file)
filename = os.path.basename(full_path) filename = os.path.basename(full_path)
@@ -61,7 +61,12 @@ def import_images_task(source_dir, dest_dir):
shutil.copy2(full_path, dest_path) shutil.copy2(full_path, dest_path)
try: try:
thumb_path = generate_thumbnail(dest_path) if dest_path.lower().endswith(('.mp4', '.mov')):
thumb_filename = f"{uuid.uuid4().hex}.jpg"
thumb_path = os.path.join("/images/thumbs", thumb_filename)
thumb_path = generate_video_thumbnail(dest_path, thumb_path)
else:
thumb_path = generate_thumbnail(dest_path)
except Exception as e: except Exception as e:
print(f"[WARN] Failed to generate thumbnail for {filename}: {e}") print(f"[WARN] Failed to generate thumbnail for {filename}: {e}")
thumb_path = None thumb_path = None
@@ -128,6 +133,24 @@ def generate_thumbnail(image_path, size=(400, 400), overwrite=False):
return str(thumb_path) return str(thumb_path)
def generate_video_thumbnail(video_path, output_path, time_position="00:00:01"):
os.makedirs(os.path.dirname(output_path), exist_ok=True)
command = [
"ffmpeg",
"-i", video_path,
"-ss", time_position,
"-vframes", "1",
"-vf", f"scale={THUMB_SIZE[0]}:-1",
output_path
]
try:
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return output_path
except subprocess.CalledProcessError as e:
print(f"[WARN] Failed to extract video thumbnail for {video_path}: {e}")
return None
def calculate_hash(file_path): def calculate_hash(file_path):
hash_func = hashlib.sha256() hash_func = hashlib.sha256()
@@ -165,6 +188,29 @@ def extract_metadata(file_path):
'taken_at': None 'taken_at': None
} }
metadata['file_size'] = os.path.getsize(file_path)
ext = os.path.splitext(file_path)[1].lower()
if ext in ('.mp4', '.mov'):
# Handle video dimensions using ffprobe
try:
import subprocess, json
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "json", file_path
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
data = json.loads(result.stdout)
stream = data.get("streams", [{}])[0]
metadata['width'] = stream.get('width')
metadata['height'] = stream.get('height')
metadata['format'] = 'mp4'
except Exception as e:
print(f"[WARN] Failed to extract video metadata: {file_path} {e}")
return metadata
try: try:
metadata['file_size'] = os.path.getsize(file_path) metadata['file_size'] = os.path.getsize(file_path)
with Image.open(file_path) as img: with Image.open(file_path) as img:
+1 -1
View File
@@ -6,7 +6,7 @@ WORKDIR /app
# Install PostgreSQL client # Install PostgreSQL client
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y postgresql-client && \ apt-get install -y postgresql-client ffmpeg && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*