From e4526ad4a415863a1f99bddd7ad9a13e57e1fd37 Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Wed, 26 Mar 2025 12:15:28 -0600 Subject: [PATCH] Updates to handle video in a dataset on ui --- ui/src/app/api/datasets/create/route.tsx | 7 +- ui/src/app/api/datasets/listImages/route.ts | 2 +- ui/src/app/api/files/[...filePath]/route.ts | 8 ++ ui/src/app/api/img/[...imagePath]/route.ts | 9 +++ ui/src/app/api/jobs/[jobID]/files/route.ts | 2 +- ui/src/app/datasets/page.tsx | 31 +++++++- ui/src/components/AddImagesModal.tsx | 3 +- ui/src/components/ConfirmModal.tsx | 36 ++++++++- ui/src/components/DatasetImageCard.tsx | 88 ++++++++++++++++----- ui/src/components/SampleImageCard.tsx | 31 ++++++-- ui/src/components/formInputs.tsx | 46 ++++++----- ui/src/hooks/useFromNull.tsx | 17 ++++ ui/src/utils/basic.ts | 6 ++ 13 files changed, 228 insertions(+), 58 deletions(-) create mode 100644 ui/src/hooks/useFromNull.tsx diff --git a/ui/src/app/api/datasets/create/route.tsx b/ui/src/app/api/datasets/create/route.tsx index f55759dc..ac4d290a 100644 --- a/ui/src/app/api/datasets/create/route.tsx +++ b/ui/src/app/api/datasets/create/route.tsx @@ -6,7 +6,10 @@ import { getDatasetsRoot } from '@/server/settings'; export async function POST(request: Request) { try { const body = await request.json(); - const { name } = body; + let { name } = body; + // clean name by making lower case, removing special characters, and replacing spaces with underscores + name = name.toLowerCase().replace(/[^a-z0-9]+/g, '_'); + let datasetsPath = await getDatasetsRoot(); let datasetPath = path.join(datasetsPath, name); @@ -15,7 +18,7 @@ export async function POST(request: Request) { fs.mkdirSync(datasetPath); } - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true, name: name }); } catch (error) { return NextResponse.json({ error: 'Failed to create dataset' }, { status: 500 }); } diff --git a/ui/src/app/api/datasets/listImages/route.ts b/ui/src/app/api/datasets/listImages/route.ts index 48f01a70..55a11057 100644 --- a/ui/src/app/api/datasets/listImages/route.ts +++ b/ui/src/app/api/datasets/listImages/route.ts @@ -36,7 +36,7 @@ export async function POST(request: Request) { * @returns Array of absolute paths to image files */ function findImagesRecursively(dir: string): string[] { - const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp']; + const imageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv']; let results: string[] = []; const items = fs.readdirSync(dir); diff --git a/ui/src/app/api/files/[...filePath]/route.ts b/ui/src/app/api/files/[...filePath]/route.ts index 38bf9d87..72c2312d 100644 --- a/ui/src/app/api/files/[...filePath]/route.ts +++ b/ui/src/app/api/files/[...filePath]/route.ts @@ -50,6 +50,14 @@ export async function GET(request: NextRequest, { params }: { params: { filePath '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.safetensors': 'application/octet-stream', + // Videos + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.mkv': 'video/x-matroska', + '.wmv': 'video/x-ms-wmv', + '.m4v': 'video/x-m4v', + '.flv': 'video/x-flv' }; const contentType = contentTypeMap[ext] || 'application/octet-stream'; diff --git a/ui/src/app/api/img/[...imagePath]/route.ts b/ui/src/app/api/img/[...imagePath]/route.ts index 8c28275e..2a67586f 100644 --- a/ui/src/app/api/img/[...imagePath]/route.ts +++ b/ui/src/app/api/img/[...imagePath]/route.ts @@ -39,6 +39,7 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat // Determine content type const ext = path.extname(filepath).toLowerCase(); const contentTypeMap: { [key: string]: string } = { + // Images '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', @@ -46,6 +47,14 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', + // Videos + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.mkv': 'video/x-matroska', + '.wmv': 'video/x-ms-wmv', + '.m4v': 'video/x-m4v', + '.flv': 'video/x-flv' }; const contentType = contentTypeMap[ext] || 'application/octet-stream'; diff --git a/ui/src/app/api/jobs/[jobID]/files/route.ts b/ui/src/app/api/jobs/[jobID]/files/route.ts index f75fe6ce..575df5e5 100644 --- a/ui/src/app/api/jobs/[jobID]/files/route.ts +++ b/ui/src/app/api/jobs/[jobID]/files/route.ts @@ -24,7 +24,7 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s return NextResponse.json({ files: [] }); } - // find all img (png, jpg, jpeg) files in the samples folder + // find all safetensors files in the job folder let files = fs .readdirSync(jobFolder) .filter(file => { diff --git a/ui/src/app/datasets/page.tsx b/ui/src/app/datasets/page.tsx index c650153d..6aa5aac2 100644 --- a/ui/src/app/datasets/page.tsx +++ b/ui/src/app/datasets/page.tsx @@ -11,8 +11,10 @@ import { openConfirm } from '@/components/ConfirmModal'; import { TopBar, MainContent } from '@/components/layout'; import UniversalTable, { TableColumn } from '@/components/UniversalTable'; import { apiClient } from '@/utils/api'; +import { useRouter } from 'next/navigation'; export default function Datasets() { + const router = useRouter(); const { datasets, status, refreshDatasets } = useDatasetList(); const [newDatasetName, setNewDatasetName] = useState(''); const [isNewDatasetModalOpen, setIsNewDatasetModalOpen] = useState(false); @@ -81,6 +83,33 @@ export default function Datasets() { } }; + const openNewDatasetModal = () => { + openConfirm({ + title: 'New Dataset', + message: 'Enter the name of the new dataset:', + type: 'info', + confirmText: 'Create', + inputTitle: 'Dataset Name', + onConfirm: async (name?: string) => { + if (!name) { + console.error('Dataset name is required.'); + return; + } + try { + const data = await apiClient.post('/api/datasets/create', { name }).then(res => res.data); + console.log('New dataset created:', data); + if (data.name) { + router.push(`/datasets/${data.name}`); + } else { + refreshDatasets(); + } + } catch (error) { + console.error('Error creating new dataset:', error); + } + }, + }); + }; + return ( <> @@ -91,7 +120,7 @@ export default function Datasets() {
diff --git a/ui/src/components/AddImagesModal.tsx b/ui/src/components/AddImagesModal.tsx index b95b512e..ff91a883 100644 --- a/ui/src/components/AddImagesModal.tsx +++ b/ui/src/components/AddImagesModal.tsx @@ -75,7 +75,8 @@ export default function AddImagesModal() { const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: { - 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], + 'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'], 'text/*': ['.txt'], }, multiple: true, diff --git a/ui/src/components/ConfirmModal.tsx b/ui/src/components/ConfirmModal.tsx index 08bdb466..6ecea813 100644 --- a/ui/src/components/ConfirmModal.tsx +++ b/ui/src/components/ConfirmModal.tsx @@ -1,15 +1,21 @@ 'use client'; +import { useRef } from 'react'; import { useState, useEffect } from 'react'; import { createGlobalState } from 'react-global-hooks'; import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'; import { FaExclamationTriangle, FaInfo } from 'react-icons/fa'; +import { TextInput } from './formInputs'; +import React from 'react'; +import { useFromNull } from '@/hooks/useFromNull'; +import classNames from 'classnames'; export interface ConfirmState { title: string; message?: string; confirmText?: string; type?: 'danger' | 'warning' | 'info'; - onConfirm?: () => void; + inputTitle?: string; + onConfirm?: (value?: string) => void | Promise; onCancel?: () => void; } @@ -22,10 +28,21 @@ export const openConfirm = (confirmProps: ConfirmState) => { export default function ConfirmModal() { const [confirm, setConfirm] = confirmstate.use(); const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + + useFromNull(() => { + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 100); + }, [confirm]); useEffect(() => { if (confirm) { setIsOpen(true); + setInputValue(''); } }, [confirm]); @@ -47,7 +64,7 @@ export default function ConfirmModal() { const onConfirm = () => { if (confirm?.onConfirm) { - confirm.onConfirm(); + confirm.onConfirm(inputValue); } setIsOpen(false); }; @@ -136,12 +153,25 @@ export default function ConfirmModal() { >
-
+
{confirm?.title}

{confirm?.message}

+
+
{ + e.preventDefault() + onConfirm() + }}> + + +
diff --git a/ui/src/components/DatasetImageCard.tsx b/ui/src/components/DatasetImageCard.tsx index 9bf58d43..6131f72c 100644 --- a/ui/src/components/DatasetImageCard.tsx +++ b/ui/src/components/DatasetImageCard.tsx @@ -1,8 +1,9 @@ import React, { useRef, useEffect, useState, ReactNode, KeyboardEvent } from 'react'; -import { FaTrashAlt } from 'react-icons/fa'; +import { FaTrashAlt, FaEye, FaEyeSlash } from 'react-icons/fa'; import { openConfirm } from './ConfirmModal'; import classNames from 'classnames'; import { apiClient } from '@/utils/api'; +import { isVideo } from '@/utils/basic'; interface DatasetImageCardProps { imageUrl: string; @@ -21,6 +22,7 @@ const DatasetImageCard: React.FC = ({ }) => { const cardRef = useRef(null); const [isVisible, setIsVisible] = useState(false); + const [inViewport, setInViewport] = useState(false); const [loaded, setLoaded] = useState(false); const [isCaptionLoaded, setIsCaptionLoaded] = useState(false); const [caption, setCaption] = useState(''); @@ -63,17 +65,25 @@ const DatasetImageCard: React.FC = ({ }); }; + // Only fetch caption when the component is both in viewport and visible useEffect(() => { - isVisible && fetchCaption(); - }, [isVisible]); + if (inViewport && isVisible) { + fetchCaption(); + } + }, [inViewport, isVisible]); useEffect(() => { - // Create intersection observer to check visibility + // Create intersection observer to check viewport visibility const observer = new IntersectionObserver( entries => { if (entries[0].isIntersecting) { - setIsVisible(true); - observer.disconnect(); + setInViewport(true); + // Initialize isVisible to true when first coming into view + if (!isVisible) { + setIsVisible(true); + } + } else { + setInViewport(false); } }, { threshold: 0.1 }, @@ -88,6 +98,13 @@ const DatasetImageCard: React.FC = ({ }; }, []); + const toggleVisibility = (): void => { + setIsVisible(prev => !prev); + if (!isVisible && !isCaptionLoaded) { + fetchCaption(); + } + }; + const handleLoad = (): void => { setLoaded(true); }; @@ -102,6 +119,8 @@ const DatasetImageCard: React.FC = ({ const isCaptionCurrent = caption.trim() === savedCaption; + const isItAVideo = isVideo(imageUrl); + return (
{/* Square image container */} @@ -111,24 +130,43 @@ const DatasetImageCard: React.FC = ({ style={{ paddingBottom: '100%' }} // Make it square >
- {isVisible && ( - {alt} + {inViewport && isVisible && ( + <> + {isItAVideo ? ( +