diff --git a/directory_scanner.py b/directory_scanner.py index 3b47a21..ba6a5cf 100644 --- a/directory_scanner.py +++ b/directory_scanner.py @@ -317,6 +317,8 @@ def _load_existing_manifest_items(directory_path): # ============================================================================= IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} +VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v"} +MEDIA_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS def scan_directory_for_images(directory_path, max_items=5000, view_subfolder=""): @@ -356,7 +358,7 @@ def scan_directory_for_images(directory_path, max_items=5000, view_subfolder="") for entry in os.scandir(scan_dir): if entry.is_file(): ext = os.path.splitext(entry.name)[1].lower() - if ext in IMAGE_EXTENSIONS: + if ext in MEDIA_EXTENSIONS: image_files.append(entry.path) except PermissionError: raise ValueError(f"Permission denied: {directory_path}") @@ -407,9 +409,11 @@ def scan_directory_for_images(directory_path, max_items=5000, view_subfolder="") def _process_single_image(file_path, directory_path, view_subfolder=""): """ - Process a single image file into a manifest-compatible item dict. + Process a single image or video file into a manifest-compatible item dict. """ filename = os.path.basename(file_path) + ext = os.path.splitext(filename)[1].lower() + is_video = ext in VIDEO_EXTENSIONS # Generate unique ID (matching existing manifest pattern) ts = int(time.time() * 100000) + random.randint(0, 1000) @@ -418,6 +422,32 @@ def _process_single_image(file_path, directory_path, view_subfolder=""): from urllib.parse import quote file_url = f"/view?filename={quote(filename, safe='')}&type=output&subfolder={quote(view_subfolder, safe='/')}" + if is_video: + # Videos: browser will update dimensions on loadedmetadata — use 16:9 default + return { + "id": ts, + "file": file_url, + "media_type": "video", + "width": 1280, + "height": 720, + "seed": 0, + "steps": 0, + "cfg": 0, + "sampler": "video", + "scheduler": "video", + "positive": "", + "negative": "", + "denoise": 1, + "model": "Video", + "lora": "None", + "clip_skip": -1, + "duration": 0, + "batch_idx": 0, + "rejected": False, + "favorited": False, + "source_file": filename, + } + # Get image dimensions from PIL try: with Image.open(file_path) as img: @@ -440,6 +470,7 @@ def _process_single_image(file_path, directory_path, view_subfolder=""): item = { "id": ts, "file": file_url, + "media_type": "image", "width": parsed.get("width") or width, "height": parsed.get("height") or height, "seed": parsed.get("seed", 0), diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 4a298b4..6cb2456 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -1249,14 +1249,24 @@ function createCard(d) { const aspectRatio = (d.width && d.height) ? (d.height / d.width) : 1; const paddingBottom = (aspectRatio * 100).toFixed(2); + // Detect video vs image and prepare variants + const isVideo = d.media_type === 'video'; + const mediaElement = isVideo + ? `` + : `Image ${d.id}`; + const reviseBtn = isVideo ? '' : ``; + const upscaleBtn = isVideo ? '' : ``; + const videoBadge = isVideo ? '
\u25B6 VIDEO
' : ''; + // FIXED LAYOUT: Star top-right, Revise below it, time bottom-right, index bottom-left card.innerHTML = `
- Image ${d.id} + ${mediaElement} - - + ${reviseBtn} + ${upscaleBtn} + ${videoBadge}
${d.duration}s
#${totalIndex}
${buildLabelOverlay(d)} @@ -1274,6 +1284,37 @@ function createCard(d) {
Size: ${d.width}x${d.height}   Seed: ${d.seed}
`; + // For videos: hover-play, click for fullscreen, update aspect ratio once metadata loads + if (isVideo) { + const videoEl = card.querySelector('video'); + if (videoEl) { + card.addEventListener('mouseenter', function() { videoEl.play().catch(function() {}); }); + card.addEventListener('mouseleave', function() { + if (!document.fullscreenElement) { videoEl.pause(); videoEl.currentTime = 0; } + }); + // Click: open native fullscreen with browser controls + videoEl.addEventListener('click', function(e) { + e.stopPropagation(); + videoEl.controls = true; + if (videoEl.requestFullscreen) videoEl.requestFullscreen().catch(function() {}); + else if (videoEl.webkitRequestFullscreen) videoEl.webkitRequestFullscreen(); + }); + // Remove controls when exiting fullscreen + videoEl.addEventListener('fullscreenchange', function() { + if (!document.fullscreenElement) { videoEl.controls = false; videoEl.pause(); } + }); + videoEl.addEventListener('loadedmetadata', function() { + if (videoEl.videoWidth && videoEl.videoHeight) { + d.width = videoEl.videoWidth; + d.height = videoEl.videoHeight; + const ar = videoEl.videoHeight / videoEl.videoWidth; + const wrapper = card.querySelector('.img-wrapper'); + if (wrapper) wrapper.style.paddingBottom = (ar * 100).toFixed(2) + '%'; + } + }); + } + } + return card; } diff --git a/resources/logic_virtual.js b/resources/logic_virtual.js index a8bd76c..a0f7a04 100644 --- a/resources/logic_virtual.js +++ b/resources/logic_virtual.js @@ -309,6 +309,11 @@ function renderVisibleItems(forcePositionUpdate = false) { img.src = img.dataset.src; img.onload = () => img.style.opacity = '1'; } + // Lazy-load videos too (same data-src → src pattern) + const vid = card.querySelector('video[data-src]'); + if (vid && !vid.src) { + vid.src = vid.dataset.src; + } } else { const currentLeft = parseInt(card.style.left) || 0; diff --git a/resources/report.css b/resources/report.css index 8cacb4b..313e858 100644 --- a/resources/report.css +++ b/resources/report.css @@ -527,19 +527,46 @@ body { border: 1px solid rgba(255,255,255,0.1); } -.index-tag { - position: absolute; - bottom: 8px; - left: 8px; - background: rgba(0,0,0,0.85); - color: rgba(255,255,255,0.95); - padding: 3px 7px; - border-radius: 4px; - font-weight: bold; - font-size: 10px; - pointer-events: none; +.index-tag { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0,0,0,0.85); + color: rgba(255,255,255,0.95); + padding: 3px 7px; + border-radius: 4px; + font-weight: bold; + font-size: 10px; + pointer-events: none; z-index: 5; - border: 1px solid rgba(255,255,255,0.2); + border: 1px solid rgba(255,255,255,0.2); +} + +.video-badge { + position: absolute; + top: 8px; + left: 8px; + background: rgba(90, 40, 160, 0.9); + color: #fff; + padding: 3px 7px; + border-radius: 4px; + font-weight: bold; + font-size: 10px; + pointer-events: none; + z-index: 5; + border: 1px solid rgba(255, 255, 255, 0.2); + text-shadow: 0 0 4px rgba(0,0,0,0.8); +} + +.card video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + cursor: pointer; + position: absolute; + top: 0; + left: 0; } .label-overlay {