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:
co-authored by
Claude Opus 4.6
parent
9d2827427f
commit
0feb11c6bd
+33
-2
@@ -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
@@ -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} <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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user