gridview / app.py
kphfrepo's picture
Update app.py
517ce26 verified
Raw History Blame Contribute Delete
22 kB
import os
import glob
import uvicorn
import urllib.parse
from urllib.parse import unquote, quote
import gradio as gr
DEFAULT_DIR = "/bucket_data"
# Authenticated WebDAV Base configuration
WEBDAV_USER = os.getenv("WEBDAV_USER")
WEBDAV_PASS = os.getenv("WEBDAV_PASS")
WEBDAV_BASE_URL = f"https://{WEBDAV_USER}:{WEBDAV_PASS}@kphfrepo-webdav.hf.space"
def load_live_cctv_dashboard():
try:
print("\nโšก Rocket-Loading local filesystem assets ...")
image_exts = ('.png', '.jpg', '.jpeg', '.webp', '.gif')
video_exts = ('.mp4', '.mkv', '.avi', '.mov', '.dav', '.ts')
grouped_assets = {}
video_count = 0
image_count = 0
for root, dirs, files in os.walk(DEFAULT_DIR):
has_video = any(f.lower().endswith(video_exts) for f in files)
if has_video:
print(f"๐ŸŽฅ Video found in '{root}'. Skipping all its subfolders.")
dirs.clear()
for filename in files:
if filename.lower().endswith('.7z') or filename == ".gitattributes":
continue
f = os.path.join(root, filename)
relative_path = os.path.relpath(f, DEFAULT_DIR).replace("\\", "/")
if "/" in relative_path:
folder_name = relative_path.split("/")[0]
else:
folder_name = "Root Directory"
if folder_name not in grouped_assets:
grouped_assets[folder_name] = {"images": [], "videos": []}
abs_path = os.path.abspath(f)
encoded_path = urllib.parse.quote(abs_path)
web_url = f"{encoded_path}"
if filename.lower().endswith(video_exts):
video_count += 1
grouped_assets[folder_name]["videos"].append({
"name": filename,
"video_url": web_url
})
elif filename.lower().endswith(image_exts):
image_count += 1
grouped_assets[folder_name]["images"].append({
"name": filename,
"image_url": web_url
})
print(f"โœ… Chunk Load Finished. Mapped {video_count} videos with URLs natively.\n")
return grouped_assets
except Exception as e:
print(f"โŒ Chunk Scan Error: {str(e)}")
return {}
# ๐Ÿ› ๏ธ JavaScript State Engine + Dynamic Multi-Frame Thumbnail Handler (FIXED URL JOIN)
custom_js = """
function() {
window.videoTimeRegistry = window.videoTimeRegistry || {};
// 2. Thumbnail Engine utilizing URL path structures safely
function initThumbStrip(placeholder) {
// 1. Guard Clauses & State Locks
if (placeholder.dataset.thumbInit === "1") return;
let base = placeholder.getAttribute('data-thumb-base');
if (!base) return;
// Ensure the base path ends with a clean slash for asset indexing
if (!base.endsWith('/')) {
base += '/';
}
placeholder.dataset.thumbInit = "1";
placeholder.style.position = "relative";
placeholder.style.overflow = "hidden";
// 2. DOM Setup for Poster Image
let thumbImg = placeholder.querySelector('.native-thumb-poster');
if (!thumbImg) {
thumbImg = document.createElement('img');
thumbImg.className = 'native-thumb-poster';
thumbImg.style.cssText = "position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; pointer-events:none;";
// Handle broken image paths gracefully without breaking layout
thumbImg.onerror = () => {
console.warn(`โš ๏ธ Thumbnail failed to load at path: ${base}thumbnail1.webp`);
thumbImg.style.display = 'none';
placeholder.style.backgroundColor = '#1e293b';
};
placeholder.appendChild(thumbImg);
}
// Keep play buttons and titles on top of the thumbnail image
placeholder.querySelectorAll('span, div').forEach((el) => {
if (el !== thumbImg) {
el.style.position = "relative";
el.style.zIndex = "2";
}
});
// 3. Asset Ingestion & Image Preloading
thumbImg.src = base + "thumbnail1.webp";
let currentFrame = 1;
let loadedFramesCount = 0;
const preloadedImages = [];
for (let i = 1; i <= 5; i++) {
const img = new Image();
img.onload = () => { loadedFramesCount++; }; // Track successfully loaded frames
img.src = base + "thumbnail" + i + ".webp";
preloadedImages.push(img);
console.log(`Loaded Thumbnail Path: ${img.src}`);
}
// 4. Safe Rotator Engine
if (placeholder.cycleInterval) clearInterval(placeholder.cycleInterval);
placeholder.cycleInterval = setInterval(() => {
// Only cycle frames if assets are completing network transit
if (loadedFramesCount >= 2) {
currentFrame = (currentFrame % 5) + 1;
thumbImg.src = base + "thumbnail" + currentFrame + ".webp";
}
}, 2000);
}
// VIEWPORT OBSERVER TO CONSERVE NETWORK TRAFFIC
const thumbObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const placeholder = entry.target;
if (entry.isIntersecting) {
initThumbStrip(placeholder);
} else {
if (placeholder.cycleInterval) {
clearInterval(placeholder.cycleInterval);
placeholder.cycleInterval = null;
}
placeholder.dataset.thumbInit = "0";
}
});
}, {
root: null,
threshold: 0.1,
rootMargin: "200px 0px"
});
function observeThumbPlaceholders() {
document.querySelectorAll('.video-placeholder').forEach((el) => {
thumbObserver.observe(el);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", observeThumbPlaceholders);
} else {
observeThumbPlaceholders();
}
const domObserver = new MutationObserver(() => {
observeThumbPlaceholders();
});
domObserver.observe(document.body, { childList: true, subtree: true });
// Video Player Stream Mounting & State Management Toggle
document.body.addEventListener('click', function(e) {
const placeholder = e.target.closest('.video-placeholder');
if (!placeholder) return;
const container = placeholder.parentElement;
const videoUrl = placeholder.getAttribute('data-video-url');
if (placeholder.cycleInterval) {
clearInterval(placeholder.cycleInterval);
}
const currentActiveVideo = document.querySelector('.active-dashboard-player');
if (currentActiveVideo) {
const activeUrl = currentActiveVideo.getAttribute('data-video-url');
window.videoTimeRegistry[activeUrl] = currentActiveVideo.currentTime;
currentActiveVideo.pause();
const activeContainer = currentActiveVideo.parentElement;
try {
const canvas = document.createElement('canvas');
canvas.width = currentActiveVideo.videoWidth || currentActiveVideo.clientWidth || 420;
canvas.height = currentActiveVideo.videoHeight || currentActiveVideo.clientHeight || 250;
canvas.style.cssText = "width: 100%; height: 100%; object-fit: contain; position: absolute; top: 0; left: 0; background: #000000;";
const ctx = canvas.getContext('2d');
ctx.drawImage(currentActiveVideo, 0, 0, canvas.width, canvas.height);
activeContainer.innerHTML = '';
activeContainer.style.position = 'relative';
activeContainer.appendChild(canvas);
} catch(err) {
activeContainer.innerHTML = `<div style="width: 100%; height: 100%; background: #1e293b;"></div>`;
}
currentActiveVideo.removeAttribute('src');
currentActiveVideo.load();
const fakePlaceholder = document.createElement('div');
fakePlaceholder.className = 'video-placeholder';
fakePlaceholder.setAttribute('data-video-url', activeUrl);
fakePlaceholder.setAttribute('data-thumb-base', placeholder.getAttribute('data-thumb-base'));
fakePlaceholder.style.cssText = "position: absolute; top: 0; left: 0; width: 100%; height: 100%; cursor: pointer; z-index: 10; display: flex; align-items: center; justify-content: center; color: white;";
fakePlaceholder.innerHTML = `<span style="font-size: 2rem; opacity: 0.4; pointer-events: none;">โ–ถ๏ธ</span>`;
activeContainer.appendChild(fakePlaceholder);
}
const savedTime = window.videoTimeRegistry[videoUrl] || 0;
container.style.position = 'relative';
container.innerHTML = `
<video class="active-dashboard-player" src="${videoUrl}" data-video-url="${videoUrl}" controls autoplay style="width: 100%; height: 100%; max-height: 250px; object-fit: contain; display: block; z-index: 5; position: relative;"></video>
`;
const newVideoPlayer = container.querySelector('video');
newVideoPlayer.addEventListener('loadedmetadata', function() {
if (savedTime > 0) {
newVideoPlayer.currentTime = savedTime;
}
});
});
}
"""
custom_img_js = """<style>.zoomable-img { cursor: zoom-in; transition: transform 0.2s ease; }.zoomable-img:hover { transform: scale(1.03); }.img-modal-overlay { display: none; position: fixed !important; z-index: 2147483647 !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; width: 100vw !important; height: 100vh !important; background-color: rgba(15, 23, 42, 0.95) !important; align-items: center !important; justify-content: center !important;}.img-modal-wrapper { position: relative; width: 100%; height: 100%; display: flex !important; align-items: center !important; justify-content: center !important; }.img-modal-content { max-width: 90% !important; max-height: 90vh !important; object-fit: contain !important; border-radius: 8px; box-shadow: 0 30px 60px -15px rgba(0, 0, 0, 0.85); animation: galleryZoom 0.15s cubic-bezier(0.16, 1, 0.3, 1); }.gallery-nav-btn { position: absolute !important; top: 50% !important; transform: translateY(-50%) !important; background: rgba(15, 23, 42, 0.85) !important; color: #ffffff !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; font-size: 2rem !important; padding: 10px 18px !important; cursor: pointer !important; border-radius: 50% !important; z-index: 2147483647 !important; user-select: none !important; }.gallery-prev { left: 40px !important; }.gallery-next { right: 40px !important; }@keyframes galleryZoom { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }</style><div id="imageModalContainer" class="img-modal-overlay" onclick="if(event.target === this) window.closeImageModal()"> <div class="img-modal-wrapper"> <button class="gallery-nav-btn gallery-prev" onclick="window.changeGalleryImage(-1, event)">โ—€</button> <img id="modalTargetImage" class="img-modal-content" src="" alt="Enlarged view" onclick="window.closeImageModal()" style="cursor: zoom-out;" /> <button class="gallery-nav-btn gallery-next" onclick="window.changeGalleryImage(1, event)">โ–ถ</button> </div></div><script>(function() { window.galleryImages = []; window.currentGalleryIndex = -1; function relocateModalToBody() { const modal = document.getElementById('imageModalContainer'); if (modal && modal.parentElement && modal.parentElement !== document.body) { document.body.appendChild(modal); } } setTimeout(relocateModalToBody, 100); setTimeout(relocateModalToBody, 500);
window.openImageModal = function(clickedElement) { relocateModalToBody(); const modal = document.getElementById('imageModalContainer'); const modalImg = document.getElementById('modalTargetImage'); if (!modal || !modalImg) return; const rawElements = document.querySelectorAll('.zoomable-img'); window.galleryImages = Array.from(rawElements).map(img => img.src); window.currentGalleryIndex = Array.from(rawElements).indexOf(clickedElement); modalImg.src = window.galleryImages[window.currentGalleryIndex]; modal.style.setProperty('display', 'flex', 'important'); }; window.changeGalleryImage = function(direction, event) { if (event) event.stopPropagation(); if (window.galleryImages.length <= 1) return; window.currentGalleryIndex = (window.currentGalleryIndex + direction + window.galleryImages.length) % window.galleryImages.length; document.getElementById('modalTargetImage').src = window.galleryImages[window.currentGalleryIndex]; }; window.closeImageModal = function() { const modal = document.getElementById('imageModalContainer'); if (modal) modal.style.setProperty('display', 'none', 'important'); }; document.addEventListener('keydown', function(e) { const modal = document.getElementById('imageModalContainer'); if (!modal || modal.style.display === 'none') return; if (e.key === 'ArrowRight' || e.key.toLowerCase() === 'd') window.changeGalleryImage(1); else if (e.key === 'ArrowLeft' || e.key.toLowerCase() === 'a') window.changeGalleryImage(-1); else if (e.key === 'Escape') window.closeImageModal(); });})();</script>"""
gr.set_static_paths(paths=[DEFAULT_DIR])
with gr.Blocks(title="CCTV Ultimate Explorer", theme=gr.themes.Soft(), js=custom_js) as demo:
init_trigger = gr.HTML(custom_img_js, visible=True)
gr.Markdown("# ๐Ÿ“น CCTV Structured Media Monitor (Instant Chunk Thumbnail View)")
refresh_btn = gr.Button("๐Ÿ”„ Sync Latest Camera Footage", variant="primary")
@gr.render(inputs=refresh_btn, triggers=[demo.load, refresh_btn.click])
def render_ui(*args):
print("\n--- [RENDER START] ---")
assets = load_live_cctv_dashboard()
if not assets:
gr.Markdown("### โŒ No media data discovered inside the local folders.")
return
html_buffer = []
for folder, media in assets.items():
html_buffer.append(f"""
<div class="folder-block" style="margin-top: 20px; margin-bottom: 40px; font-family: system-ui, sans-serif; clear: both; display: block;">
<hr style='border: 0; border-top: 2px solid #4ade80; margin: 20px 0 30px 0;'/>
<div style="margin-bottom: 20px;">
<h2 style="margin: 0; font-size: 1.4rem; color: #1f2937; display: flex; align-items: center; gap: 8px; font-weight: 700;">
<span>๐Ÿ“</span> Camera Channel / Directory:
<code style="background: #eef2f6; color: #1e293b; padding: 4px 10px; border-radius: 6px; font-size: 1.1rem; border: 1px solid #cbd5e1; font-family: monospace;">{folder}</code>
</h2>
</div>
""")
if media["videos"]:
html_buffer.append('<h3 style="color: #4b5563; font-size: 1.05rem; font-weight: 600; margin: 15px 0 12px 0;">๐ŸŽฌ Playable Video Feeds</h3>')
html_buffer.append('<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 20px; margin-bottom: 25px; width: 100%;">')
for video_item in media["videos"]:
video_path = video_item['video_url'].lstrip('/')
public_video_url = f"{WEBDAV_BASE_URL}/{video_path}"
# print(f"{public_video_url}")
decoded_path = unquote(video_path)
# ๐Ÿ› ๏ธ STRIP EXTENSION FOR BASE URL LOOKUP:
# Trims path from "folder/clip.mp4" down to "folder/clip"
path_without_ext, _ = os.path.splitext(decoded_path)
thumb_folder_encoded = quote(path_without_ext, safe="/")
thumb_base_url = f"{WEBDAV_BASE_URL}/{thumb_folder_encoded}"
# print(f"{thumb_base_url}")
html_buffer.append(f"""
<div style="background: #ffffff; border: 1px solid #e2e8f0; padding: 14px; border-radius: 10px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); display: flex; flex-direction: column; justify-content: space-between; max-width: 100%; box-sizing: border-box;">
<p style="margin: 0 0 10px 0; font-size: 13px; color: #334155; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;" title="{video_item['name']}">
<strong>Clip ID:</strong> <code style="background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 12px; color: #0f172a;">{video_item['name']}</code>
</p>
<div class="video-wrapper" style="background: #000000; border-radius: 6px; overflow: hidden; width: 100%; height: 250px; position: relative; display: flex; align-items: center; justify-content: center;">
<div class="video-placeholder" data-video-url="{public_video_url}" data-thumb-base="{thumb_base_url}" style="width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #1e293b; cursor: pointer; color: white; position: relative; z-index: 2;">
<span style="font-size: 3rem; z-index: 3;">โ–ถ๏ธ</span>
<span style="font-size: 12px; margin-top: 8px; opacity: 0.9; z-index: 3; text-shadow: 0px 2px 4px rgba(0,0,0,0.8);">Click to Stream Feed</span>
</div>
</div>
</div>
""")
html_buffer.append('</div>')
else:
html_buffer.append('<p style="font-style: italic; color: #94a3b8; margin: 10px 0 25px 10px; font-size: 14px;">*No video tracks detected.*</p>')
if media["images"]:
html_buffer.append('<h3 style="color: #4b5563; font-size: 1.05rem; font-weight: 600; margin: 15px 0 12px 0;">๐Ÿ–ผ Camera Snapshots Gallery</h3>')
html_buffer.append('<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; margin-bottom: 25px; width: 100%;">')
for img_item in media["images"]:
img_path = img_item['image_url'].lstrip('/')
public_image_url = f"{WEBDAV_BASE_URL}/{img_path}"
html_buffer.append(f"""
<div style="border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background: #0f172a; height: 160px; display: flex; align-items: center; justify-content: center;">
<img class="zoomable-img" src="{public_image_url}" loading="lazy" onclick="window.openImageModal(this)" style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;" />
</div>
""")
html_buffer.append('</div>')
html_buffer.append('</div>')
gr.HTML("".join(html_buffer))
print("--- [RENDER END] ---\n")
if __name__ == "__main__":
demo.launch(ssr_mode=False, allowed_paths=["/bucket_data"])