mirror of
https://github.com/ostris/ai-toolkit.git
synced 2026-01-26 16:39:47 +00:00
Updates to handle video in a dataset on ui
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
17
ui/src/hooks/useFromNull.tsx
Normal file
17
ui/src/hooks/useFromNull.tsx
Normal 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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user