feat: video support in Dashboard and external directory scanner

Adds video file support throughout the Dashboard with zero new Python
dependencies — browsers handle all playback, metadata, and thumbnails
natively via HTML5 <video> elements.

directory_scanner.py:
- Add VIDEO_EXTENSIONS (.mp4, .webm, .mov, .m4v) to scan list
- _process_single_image() detects videos by extension and creates a
  video manifest item with media_type="video"
- Videos get default 1280x720 dimensions; browser updates on loadedmetadata
- Image items now tagged with media_type="image" for symmetry

Dashboard (logic_ui.js createCard):
- Renders <video preload="none" muted loop playsinline> for video items
- Hides REVISE and upscale buttons for videos (don't apply)
- Shows "▶ VIDEO" badge in top-left corner
- Hover-play: videos play on mouseenter, pause+reset on mouseleave
- Click: requests native browser fullscreen with controls
- loadedmetadata handler updates card aspect ratio from videoWidth/videoHeight

Dashboard (logic_virtual.js):
- Lazy loader extended to swap data-src → src for <video> elements

CSS (report.css):
- .video-badge styling (purple pill, top-left)
- .card video positioning (absolute, cover-fit)

Works with any codec the browser supports (H.264, VP9, AV1, H.265).
Existing features unchanged: favorites, rejects, sorting, filtering,
DOM card pool, merge sessions, quick favorite hotkeys all work on videos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-04-10 14:23:02 -07:00
co-authored by Claude Opus 4.6
parent 9d2827427f
commit 0feb11c6bd
4 changed files with 121 additions and 17 deletions
+33 -2
View File
@@ -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),
+44 -3
View File
@@ -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
? `<video ondblclick="toggleFavorite(this)" data-src="${d.file}" muted loop playsinline preload="none" draggable="false"></video>`
: `<img ondblclick="toggleFavorite(this)" data-src="${d.file}" alt="Image ${d.id}" draggable="false">`;
const reviseBtn = isVideo ? '' : `<button class="revise-btn" onclick="openM(${d.id})">REVISE</button>`;
const upscaleBtn = isVideo ? '' : `<button class="upscale-btn" onclick="openUpscaleModal(${d.id})" title="Upscale this image">\u2B06</button>`;
const videoBadge = isVideo ? '<div class="video-badge">\u25B6 VIDEO</div>' : '';
// FIXED LAYOUT: Star top-right, Revise below it, time bottom-right, index bottom-left
card.innerHTML = `
<div class="img-wrapper" style="padding-bottom: ${paddingBottom}%;">
<img ondblclick="toggleFavorite(this)" data-src="${d.file}" alt="Image ${d.id}" draggable="false">
${mediaElement}
<button class="reject-btn" onclick="rejectItem(this)">✕</button>
<button class="favorite-btn ${favClass}" onclick="toggleFavorite(this)">${favIcon}</button>
<button class="revise-btn" onclick="openM(${d.id})">REVISE</button>
<button class="upscale-btn" onclick="openUpscaleModal(${d.id})" title="Upscale this image">⬆</button>
${reviseBtn}
${upscaleBtn}
${videoBadge}
<div class="time-tag">${d.duration}s</div>
<div class="index-tag">#${totalIndex}</div>
${buildLabelOverlay(d)}
@@ -1274,6 +1284,37 @@ function createCard(d) {
<div class="stat"><b>Size:</b> ${d.width}x${d.height} &nbsp; <b>Seed:</b> ${d.seed}</div>
</div>`;
// 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;
}
+5
View File
@@ -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;
+39 -12
View File
@@ -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 {