Updates to handle video in a dataset on ui

This commit is contained in:
Jaret Burkett
2025-03-26 12:15:28 -06:00
parent 4595965e06
commit e4526ad4a4
13 changed files with 228 additions and 58 deletions

View File

@@ -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 });
}

View File

@@ -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);

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 => {

View File

@@ -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 (
<>
<TopBar>
@@ -91,7 +120,7 @@ export default function Datasets() {
<div>
<Button
className="text-gray-200 bg-slate-600 px-4 py-2 rounded-md hover:bg-slate-500 transition-colors"
onClick={() => setIsNewDatasetModalOpen(true)}
onClick={() => openNewDatasetModal()}
>
New Dataset
</Button>

View File

@@ -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,

View File

@@ -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<void>;
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<string>('');
const inputRef = useRef<HTMLInputElement>(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() {
>
<Icon aria-hidden="true" className={`size-6 ${getTextColor()}`} />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<DialogTitle as="h3" className={`text-base font-semibold ${getTitleColor()}`}>
{confirm?.title}
</DialogTitle>
<div className="mt-2">
<p className="text-sm text-gray-200">{confirm?.message}</p>
<div className={classNames('mt-4 w-full', { hidden: !confirm?.inputTitle })}>
<form onSubmit={(e) => {
e.preventDefault()
onConfirm()
}}>
<TextInput
value={inputValue}
ref={inputRef}
onChange={setInputValue}
placeholder={confirm?.inputTitle}
/>
</form>
</div>
</div>
</div>
</div>

View File

@@ -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<DatasetImageCardProps> = ({
}) => {
const cardRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState<boolean>(false);
const [inViewport, setInViewport] = useState<boolean>(false);
const [loaded, setLoaded] = useState<boolean>(false);
const [isCaptionLoaded, setIsCaptionLoaded] = useState<boolean>(false);
const [caption, setCaption] = useState<string>('');
@@ -63,17 +65,25 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
});
};
// 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<DatasetImageCardProps> = ({
};
}, []);
const toggleVisibility = (): void => {
setIsVisible(prev => !prev);
if (!isVisible && !isCaptionLoaded) {
fetchCaption();
}
};
const handleLoad = (): void => {
setLoaded(true);
};
@@ -102,6 +119,8 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
const isCaptionCurrent = caption.trim() === savedCaption;
const isItAVideo = isVideo(imageUrl);
return (
<div className={`flex flex-col ${className}`}>
{/* Square image container */}
@@ -111,24 +130,43 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
style={{ paddingBottom: '100%' }} // Make it square
>
<div className="absolute inset-0 rounded-t-lg shadow-md">
{isVisible && (
<img
src={`/api/img/${encodeURIComponent(imageUrl)}`}
alt={alt}
onLoad={handleLoad}
className={`w-full h-full object-contain transition-opacity duration-300 ${
loaded ? 'opacity-100' : 'opacity-0'
}`}
/>
{inViewport && isVisible && (
<>
{isItAVideo ? (
<video
src={`/api/img/${encodeURIComponent(imageUrl)}`}
className={`w-full h-full object-contain`}
autoPlay={false}
loop
muted
controls
/>
) : (
<img
src={`/api/img/${encodeURIComponent(imageUrl)}`}
alt={alt}
onLoad={handleLoad}
className={`w-full h-full object-contain transition-opacity duration-300 ${
loaded ? 'opacity-100' : 'opacity-0'
}`}
/>
)}
</>
)}
{!isVisible && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-800 bg-opacity-75 rounded-t-lg">
<span className="text-white text-lg"></span>
</div>
)}
{children && <div className="absolute inset-0 flex items-center justify-center">{children}</div>}
<div className="absolute top-1 right-1">
<div className="absolute top-1 right-1 flex space-x-2">
<button
className="bg-gray-800 rounded-full p-2"
onClick={() => {
openConfirm({
title: 'Delete Image',
message: 'Are you sure you want to delete this image? This action cannot be undone.',
title: `Delete ${isItAVideo ? 'video' : 'image'}`,
message: `Are you sure you want to delete this ${isItAVideo ? 'video' : 'image'}? This action cannot be undone.`,
type: 'warning',
confirmText: 'Delete',
onConfirm: () => {
@@ -158,7 +196,7 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
'border-transparent border-2': isCaptionCurrent,
})}
>
{isVisible && isCaptionLoaded && (
{inViewport && isVisible && isCaptionLoaded && (
<form
onSubmit={e => {
e.preventDefault();
@@ -175,9 +213,19 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
/>
</form>
)}
{(!inViewport || !isVisible) && isCaptionLoaded && (
<div className="w-full h-full flex items-center justify-center text-gray-400">
{isVisible ? "Scroll into view to edit caption" : "Show content to edit caption"}
</div>
)}
{!isCaptionLoaded && (
<div className="w-full h-full flex items-center justify-center text-gray-400">
Loading caption...
</div>
)}
</div>
</div>
);
};
export default DatasetImageCard;
export default DatasetImageCard;

View File

@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useState, ReactNode } from 'react';
import { sampleImageModalState } from '@/components/SampleImageModal';
import { isVideo } from '@/utils/basic';
interface SampleImageCardProps {
imageUrl: string;
@@ -47,6 +48,7 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
const handleLoad = (): void => {
setLoaded(true);
};
console.log('imgurl',imageUrl.toLowerCase().slice(-4))
return (
<div className={`flex flex-col ${className}`}>
@@ -59,14 +61,27 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
>
<div className="absolute inset-0 rounded-t-lg shadow-md">
{isVisible && (
<img
src={`/api/img/${encodeURIComponent(imageUrl)}`}
alt={alt}
onLoad={handleLoad}
className={`w-full h-full object-contain transition-opacity duration-300 ${
loaded ? 'opacity-100' : 'opacity-0'
}`}
/>
<>
{isVideo(imageUrl) ? (
<video
src={`/api/img/${encodeURIComponent(imageUrl)}`}
className={`w-full h-full object-cover`}
autoPlay={false}
loop
muted
playsInline
/>
) : (
<img
src={`/api/img/${encodeURIComponent(imageUrl)}`}
alt={alt}
onLoad={handleLoad}
className={`w-full h-full object-cover transition-opacity duration-300 ${
loaded ? 'opacity-100' : 'opacity-0'
}`}
/>
)}
</>
)}
{children && <div className="absolute inset-0 flex items-center justify-center">{children}</div>}
</div>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef } from 'react';
import classNames from 'classnames';
const labelClasses = 'block text-xs mb-1 mt-2 text-gray-300';
@@ -19,26 +19,30 @@ export interface TextInputProps extends InputProps {
disabled?: boolean;
}
export const TextInput = (props: TextInputProps) => {
const { label, value, onChange, placeholder, required, disabled } = props;
return (
<div className={classNames(props.className)}>
{label && <label className={labelClasses}>{label}</label>}
<input
type={props.type || 'text'}
value={value}
onChange={e => {
if (disabled) return;
onChange(e.target.value);
}}
className={`${inputClasses} ${disabled && 'opacity-30 cursor-not-allowed'}`}
placeholder={placeholder}
required={required}
disabled={disabled}
/>
</div>
);
};
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ label, value, onChange, placeholder, required, disabled, type = 'text', className }, ref) => {
return (
<div className={classNames(className)}>
{label && <label className={labelClasses}>{label}</label>}
<input
ref={ref}
type={type}
value={value}
onChange={e => {
if (!disabled) onChange(e.target.value);
}}
className={`${inputClasses} ${disabled ? 'opacity-30 cursor-not-allowed' : ''}`}
placeholder={placeholder}
required={required}
disabled={disabled}
/>
</div>
);
}
);
// 👇 Helpful for debugging
TextInput.displayName = 'TextInput';
export interface NumberInputProps extends InputProps {
value: number;

View File

@@ -0,0 +1,17 @@
import { useEffect, useRef } from 'react';
export function useFromNull(effect: () => void | (() => void), deps: Array<any | null | undefined>) {
const prevDepsRef = useRef<(any | null | undefined)[]>([]);
useEffect(() => {
const shouldRun = deps.some((dep, i) => prevDepsRef.current[i] == null && dep != null);
if (shouldRun) {
const cleanup = effect();
prevDepsRef.current = deps;
return cleanup;
}
prevDepsRef.current = deps;
}, deps);
}

View File

@@ -3,3 +3,9 @@ export const objectCopy = <T>(obj: T): T => {
};
export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const imgExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'];
export const videoExtensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv'];
export const isVideo = (filePath: string) => videoExtensions.includes(filePath.toLowerCase().slice(-4));
export const isImage = (filePath: string) => imgExtensions.includes(filePath.toLowerCase().slice(-4));