mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-30 17:11:14 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
719 lines
24 KiB
TypeScript
719 lines
24 KiB
TypeScript
import { Button, buttonVariants } from "@/components/ui/button";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
import axios from "axios";
|
|
import { toast } from "sonner";
|
|
import { Step1FormData } from "./Step1NameAndDefine";
|
|
import { Step2FormData } from "./Step2StateArea";
|
|
import useSWR from "swr";
|
|
import { baseUrl } from "@/api/baseUrl";
|
|
import { isMobile } from "react-device-detect";
|
|
import { cn } from "@/lib/utils";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|
import { IoIosWarning } from "react-icons/io";
|
|
import { LuRefreshCw } from "react-icons/lu";
|
|
|
|
export type Step3FormData = {
|
|
examplesGenerated: boolean;
|
|
imageClassifications?: { [imageName: string]: string };
|
|
};
|
|
|
|
type Step3ChooseExamplesProps = {
|
|
step1Data: Step1FormData;
|
|
step2Data?: Step2FormData;
|
|
initialData?: Partial<Step3FormData>;
|
|
onClose: () => void;
|
|
onBack: () => void;
|
|
};
|
|
|
|
export default function Step3ChooseExamples({
|
|
step1Data,
|
|
step2Data,
|
|
initialData,
|
|
onClose,
|
|
onBack,
|
|
}: Step3ChooseExamplesProps) {
|
|
const { t } = useTranslation(["views/classificationModel"]);
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [hasGenerated, setHasGenerated] = useState(
|
|
initialData?.examplesGenerated || false,
|
|
);
|
|
const [imageClassifications, setImageClassifications] = useState<{
|
|
[imageName: string]: string;
|
|
}>(initialData?.imageClassifications || {});
|
|
const [isTraining, setIsTraining] = useState(false);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [currentClassIndex, setCurrentClassIndex] = useState(0);
|
|
const [selectedImages, setSelectedImages] = useState<Set<string>>(new Set());
|
|
const [cacheKey, setCacheKey] = useState<number>(Date.now());
|
|
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
|
|
const [showRefreshConfirm, setShowRefreshConfirm] = useState(false);
|
|
|
|
const handleImageLoad = useCallback((imageName: string) => {
|
|
setLoadedImages((prev) => new Set(prev).add(imageName));
|
|
}, []);
|
|
|
|
const { data: trainImages, mutate: refreshTrainImages } = useSWR<string[]>(
|
|
hasGenerated ? `classification/${step1Data.modelName}/train` : null,
|
|
);
|
|
|
|
const unknownImages = useMemo(() => {
|
|
if (!trainImages) return [];
|
|
return trainImages;
|
|
}, [trainImages]);
|
|
|
|
const toggleImageSelection = useCallback((imageName: string) => {
|
|
setSelectedImages((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(imageName)) {
|
|
newSet.delete(imageName);
|
|
} else {
|
|
newSet.add(imageName);
|
|
}
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
// Get all classes (excluding "none" - it will be auto-assigned)
|
|
const allClasses = useMemo(() => {
|
|
return [...step1Data.classes];
|
|
}, [step1Data.classes]);
|
|
|
|
const currentClass = allClasses[currentClassIndex];
|
|
|
|
const processClassificationsAndTrain = useCallback(
|
|
async (classifications: { [imageName: string]: string }) => {
|
|
// Step 1: Create config for the new model
|
|
const modelConfig: {
|
|
enabled: boolean;
|
|
name: string;
|
|
threshold: number;
|
|
state_config?: {
|
|
cameras: Record<string, { crop: number[] }>;
|
|
motion: boolean;
|
|
};
|
|
object_config?: { objects: string[]; classification_type: string };
|
|
} = {
|
|
enabled: true,
|
|
name: step1Data.modelName,
|
|
threshold: 0.8,
|
|
};
|
|
|
|
if (step1Data.modelType === "state") {
|
|
// State model config
|
|
const cameras: Record<string, { crop: number[] }> = {};
|
|
step2Data?.cameraAreas.forEach((area) => {
|
|
cameras[area.camera] = {
|
|
crop: area.crop,
|
|
};
|
|
});
|
|
|
|
modelConfig.state_config = {
|
|
cameras,
|
|
motion: true,
|
|
};
|
|
} else {
|
|
// Object model config
|
|
modelConfig.object_config = {
|
|
objects: step1Data.objectLabel ? [step1Data.objectLabel] : [],
|
|
classification_type: step1Data.objectType || "sub_label",
|
|
} as { objects: string[]; classification_type: string };
|
|
}
|
|
|
|
// Update config via config API
|
|
await axios.put("/config/set", {
|
|
requires_restart: 0,
|
|
update_topic: `config/classification/custom/${step1Data.modelName}`,
|
|
config_data: {
|
|
classification: {
|
|
custom: {
|
|
[step1Data.modelName]: modelConfig,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Step 2: Classify each image by moving it to the correct category folder
|
|
const categorizePromises = Object.entries(classifications).map(
|
|
([imageName, className]) => {
|
|
if (!className) return Promise.resolve();
|
|
return axios.post(
|
|
`/classification/${step1Data.modelName}/dataset/categorize`,
|
|
{
|
|
training_file: imageName,
|
|
category: className === "none" ? "none" : className,
|
|
},
|
|
);
|
|
},
|
|
);
|
|
await Promise.all(categorizePromises);
|
|
|
|
// Step 2.5: Delete any unselected images from train folder
|
|
// For state models, all images must be classified, so unselected images should be removed
|
|
// For object models, unselected images are assigned to "none" so they're already categorized
|
|
if (step1Data.modelType === "state") {
|
|
try {
|
|
// Fetch current train images to see what's left after categorization
|
|
const trainImagesResponse = await axios.get<string[]>(
|
|
`/classification/${step1Data.modelName}/train`,
|
|
);
|
|
const remainingTrainImages = trainImagesResponse.data || [];
|
|
|
|
const categorizedImageNames = new Set(Object.keys(classifications));
|
|
const unselectedImages = remainingTrainImages.filter(
|
|
(imageName) => !categorizedImageNames.has(imageName),
|
|
);
|
|
|
|
if (unselectedImages.length > 0) {
|
|
await axios.post(
|
|
`/classification/${step1Data.modelName}/train/delete`,
|
|
{
|
|
ids: unselectedImages,
|
|
},
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// Silently fail - unselected images will remain but won't cause issues
|
|
// since the frontend filters out images that don't match expected format
|
|
}
|
|
}
|
|
|
|
// Step 2.6: Create empty folders for classes that don't have any images
|
|
// This ensures all classes are available in the dataset view later
|
|
const classesWithImages = new Set(
|
|
Object.values(classifications).filter((c) => c && c !== "none"),
|
|
);
|
|
const emptyFolderPromises = step1Data.classes
|
|
.filter((className) => !classesWithImages.has(className))
|
|
.map((className) =>
|
|
axios.post(
|
|
`/classification/${step1Data.modelName}/dataset/${className}/create`,
|
|
),
|
|
);
|
|
await Promise.all(emptyFolderPromises);
|
|
|
|
// Step 3: Determine if we should train
|
|
// For state models, we need ALL states to have examples (at least 2 states)
|
|
// For object models, we need at least 1 class with images (the rest go to "none")
|
|
const allStatesHaveExamplesForTraining =
|
|
step1Data.modelType !== "state" ||
|
|
step1Data.classes.every((className) =>
|
|
classesWithImages.has(className),
|
|
);
|
|
const shouldTrain =
|
|
step1Data.modelType === "object"
|
|
? classesWithImages.size >= 1
|
|
: allStatesHaveExamplesForTraining && classesWithImages.size >= 2;
|
|
|
|
// Step 4: Kick off training only if we have enough classes with images
|
|
if (shouldTrain) {
|
|
await axios.post(`/classification/${step1Data.modelName}/train`);
|
|
|
|
toast.success(t("wizard.step3.trainingStarted"), {
|
|
closeButton: true,
|
|
});
|
|
setIsTraining(true);
|
|
} else {
|
|
// Don't train - not all states have examples
|
|
toast.success(t("wizard.step3.modelCreated"), {
|
|
closeButton: true,
|
|
});
|
|
setIsTraining(false);
|
|
onClose();
|
|
}
|
|
},
|
|
[step1Data, step2Data, t, onClose],
|
|
);
|
|
|
|
const handleContinueClassification = useCallback(async () => {
|
|
// Mark selected images with current class
|
|
const newClassifications = { ...imageClassifications };
|
|
|
|
// Handle user going back and de-selecting images
|
|
const imagesToCheck = unknownImages.slice(0, 24);
|
|
imagesToCheck.forEach((imageName) => {
|
|
if (
|
|
newClassifications[imageName] === currentClass &&
|
|
!selectedImages.has(imageName)
|
|
) {
|
|
delete newClassifications[imageName];
|
|
}
|
|
});
|
|
|
|
// Then, add all currently selected images to the current class
|
|
selectedImages.forEach((imageName) => {
|
|
newClassifications[imageName] = currentClass;
|
|
});
|
|
|
|
// Check if we're on the last class to select
|
|
const isLastClass = currentClassIndex === allClasses.length - 1;
|
|
|
|
if (isLastClass) {
|
|
// For object models, assign remaining unclassified images to "none"
|
|
// For state models, this should never happen since we require all images to be classified
|
|
if (step1Data.modelType !== "state") {
|
|
unknownImages.slice(0, 24).forEach((imageName) => {
|
|
if (!newClassifications[imageName]) {
|
|
newClassifications[imageName] = "none";
|
|
}
|
|
});
|
|
}
|
|
|
|
// All done, trigger training immediately
|
|
setImageClassifications(newClassifications);
|
|
setIsProcessing(true);
|
|
|
|
try {
|
|
await processClassificationsAndTrain(newClassifications);
|
|
} catch (error) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string; detail?: string } };
|
|
message?: string;
|
|
};
|
|
const errorMessage =
|
|
axiosError.response?.data?.message ||
|
|
axiosError.response?.data?.detail ||
|
|
axiosError.message ||
|
|
"Failed to classify images";
|
|
|
|
toast.error(
|
|
t("wizard.step3.errors.classifyFailed", { error: errorMessage }),
|
|
);
|
|
setIsProcessing(false);
|
|
}
|
|
} else {
|
|
// Move to next class
|
|
setImageClassifications(newClassifications);
|
|
setCurrentClassIndex((prev) => prev + 1);
|
|
setSelectedImages(new Set());
|
|
}
|
|
}, [
|
|
selectedImages,
|
|
currentClass,
|
|
currentClassIndex,
|
|
allClasses,
|
|
imageClassifications,
|
|
unknownImages,
|
|
step1Data,
|
|
processClassificationsAndTrain,
|
|
t,
|
|
]);
|
|
|
|
const generateExamples = useCallback(async () => {
|
|
setIsGenerating(true);
|
|
|
|
try {
|
|
if (step1Data.modelType === "state") {
|
|
// For state models, use cameras and crop areas
|
|
if (!step2Data?.cameraAreas || step2Data.cameraAreas.length === 0) {
|
|
toast.error(t("wizard.step3.errors.noCameras"));
|
|
setIsGenerating(false);
|
|
return;
|
|
}
|
|
|
|
const cameras: { [key: string]: [number, number, number, number] } = {};
|
|
step2Data.cameraAreas.forEach((area) => {
|
|
cameras[area.camera] = area.crop;
|
|
});
|
|
|
|
await axios.post("/classification/generate_examples/state", {
|
|
model_name: step1Data.modelName,
|
|
cameras,
|
|
});
|
|
} else {
|
|
// For object models, use label
|
|
if (!step1Data.objectLabel) {
|
|
toast.error(t("wizard.step3.errors.noObjectLabel"));
|
|
setIsGenerating(false);
|
|
return;
|
|
}
|
|
|
|
// For now, use all enabled cameras
|
|
// TODO: In the future, we might want to let users select specific cameras
|
|
await axios.post("/classification/generate_examples/object", {
|
|
model_name: step1Data.modelName,
|
|
label: step1Data.objectLabel,
|
|
});
|
|
}
|
|
|
|
setHasGenerated(true);
|
|
toast.success(t("wizard.step3.generateSuccess"));
|
|
|
|
// Update cache key to force image reload
|
|
setCacheKey(Date.now());
|
|
await refreshTrainImages();
|
|
} catch (error) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string; detail?: string } };
|
|
message?: string;
|
|
};
|
|
const errorMessage =
|
|
axiosError.response?.data?.message ||
|
|
axiosError.response?.data?.detail ||
|
|
axiosError.message ||
|
|
"Failed to generate examples";
|
|
|
|
toast.error(
|
|
t("wizard.step3.errors.generateFailed", { error: errorMessage }),
|
|
);
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
}, [step1Data, step2Data, t, refreshTrainImages]);
|
|
|
|
useEffect(() => {
|
|
if (!hasGenerated && !isGenerating) {
|
|
generateExamples();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const handleContinue = useCallback(async () => {
|
|
setIsProcessing(true);
|
|
try {
|
|
await processClassificationsAndTrain(imageClassifications);
|
|
} catch (error) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string; detail?: string } };
|
|
message?: string;
|
|
};
|
|
const errorMessage =
|
|
axiosError.response?.data?.message ||
|
|
axiosError.response?.data?.detail ||
|
|
axiosError.message ||
|
|
"Failed to classify images";
|
|
|
|
toast.error(
|
|
t("wizard.step3.errors.classifyFailed", { error: errorMessage }),
|
|
);
|
|
setIsProcessing(false);
|
|
}
|
|
}, [imageClassifications, processClassificationsAndTrain, t]);
|
|
|
|
const unclassifiedImages = useMemo(() => {
|
|
if (!unknownImages) return [];
|
|
const images = unknownImages.slice(0, 24);
|
|
|
|
// Only filter if we have any classifications
|
|
if (Object.keys(imageClassifications).length === 0) {
|
|
return images;
|
|
}
|
|
|
|
// If we're viewing a previous class (going back), show images for that class
|
|
// Otherwise show only unclassified images
|
|
const currentClassInView = allClasses[currentClassIndex];
|
|
return images.filter((img) => {
|
|
const imgClass = imageClassifications[img];
|
|
// Show if: unclassified OR classified with current class we're viewing
|
|
return !imgClass || imgClass === currentClassInView;
|
|
});
|
|
}, [unknownImages, imageClassifications, allClasses, currentClassIndex]);
|
|
|
|
const allImagesClassified = useMemo(() => {
|
|
return unclassifiedImages.length === 0;
|
|
}, [unclassifiedImages]);
|
|
|
|
const isLastClass = currentClassIndex === allClasses.length - 1;
|
|
const statesWithExamples = useMemo(() => {
|
|
if (step1Data.modelType !== "state") return new Set<string>();
|
|
|
|
const states = new Set<string>();
|
|
const allImages = unknownImages.slice(0, 24);
|
|
|
|
// Check which states have at least one image classified
|
|
allImages.forEach((img) => {
|
|
let className: string | undefined;
|
|
if (selectedImages.has(img)) {
|
|
className = currentClass;
|
|
} else {
|
|
className = imageClassifications[img];
|
|
}
|
|
if (className && allClasses.includes(className)) {
|
|
states.add(className);
|
|
}
|
|
});
|
|
|
|
return states;
|
|
}, [
|
|
step1Data.modelType,
|
|
unknownImages,
|
|
imageClassifications,
|
|
selectedImages,
|
|
currentClass,
|
|
allClasses,
|
|
]);
|
|
|
|
const allStatesHaveExamples = useMemo(() => {
|
|
if (step1Data.modelType !== "state") return true;
|
|
return allClasses.every((className) => statesWithExamples.has(className));
|
|
}, [step1Data.modelType, allClasses, statesWithExamples]);
|
|
|
|
const hasUnclassifiedImages = useMemo(() => {
|
|
if (!unknownImages) return false;
|
|
const allImages = unknownImages.slice(0, 24);
|
|
return allImages.some((img) => !imageClassifications[img]);
|
|
}, [unknownImages, imageClassifications]);
|
|
|
|
const showMissingStatesWarning = useMemo(() => {
|
|
return (
|
|
step1Data.modelType === "state" &&
|
|
isLastClass &&
|
|
!allStatesHaveExamples &&
|
|
!hasUnclassifiedImages &&
|
|
hasGenerated
|
|
);
|
|
}, [
|
|
step1Data.modelType,
|
|
isLastClass,
|
|
allStatesHaveExamples,
|
|
hasUnclassifiedImages,
|
|
hasGenerated,
|
|
]);
|
|
|
|
const handleBack = useCallback(() => {
|
|
if (currentClassIndex > 0) {
|
|
const previousClass = allClasses[currentClassIndex - 1];
|
|
setCurrentClassIndex((prev) => prev - 1);
|
|
|
|
// Restore selections for the previous class
|
|
const previousSelections = Object.entries(imageClassifications)
|
|
.filter(([_, className]) => className === previousClass)
|
|
.map(([imageName, _]) => imageName);
|
|
setSelectedImages(new Set(previousSelections));
|
|
} else {
|
|
onBack();
|
|
}
|
|
}, [currentClassIndex, allClasses, imageClassifications, onBack]);
|
|
|
|
const doRefresh = useCallback(() => {
|
|
setCurrentClassIndex(0);
|
|
setSelectedImages(new Set());
|
|
setImageClassifications({});
|
|
setLoadedImages(new Set());
|
|
setShowRefreshConfirm(false);
|
|
generateExamples();
|
|
}, [generateExamples]);
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
if (Object.keys(imageClassifications).length > 0) {
|
|
setShowRefreshConfirm(true);
|
|
} else {
|
|
doRefresh();
|
|
}
|
|
}, [imageClassifications, doRefresh]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<AlertDialog
|
|
open={showRefreshConfirm}
|
|
onOpenChange={setShowRefreshConfirm}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("wizard.step3.refreshConfirm.title")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("wizard.step3.refreshConfirm.description")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={doRefresh}
|
|
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
>
|
|
{t("button.continue", { ns: "common" })}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{isTraining ? (
|
|
<div className="flex flex-col items-center gap-6 py-12">
|
|
<ActivityIndicator className="size-12" />
|
|
<div className="text-center">
|
|
<h3 className="mb-2 text-lg font-medium">
|
|
{t("wizard.step3.training.title")}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("wizard.step3.training.description")}
|
|
</p>
|
|
</div>
|
|
<Button onClick={onClose} variant="select" className="mt-4">
|
|
{t("button.close", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
) : isGenerating ? (
|
|
<div className="flex h-[50vh] flex-col items-center justify-center gap-4">
|
|
<ActivityIndicator className="size-12" />
|
|
<div className="text-center">
|
|
<h3 className="mb-2 text-lg font-medium">
|
|
{t("wizard.step3.generating.title")}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("wizard.step3.generating.description")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : hasGenerated ? (
|
|
<div className="relative flex flex-col gap-4">
|
|
<Tooltip open={showRefreshConfirm ? false : undefined}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-0 top-0 size-8"
|
|
onClick={handleRefresh}
|
|
disabled={isGenerating || isProcessing}
|
|
>
|
|
<LuRefreshCw className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipPortal>
|
|
<TooltipContent>
|
|
{t("wizard.step3.refreshExamples")}
|
|
</TooltipContent>
|
|
</TooltipPortal>
|
|
</Tooltip>
|
|
{showMissingStatesWarning && (
|
|
<Alert variant="destructive">
|
|
<IoIosWarning className="size-5" />
|
|
<AlertTitle>
|
|
{t("wizard.step3.missingStatesWarning.title")}
|
|
</AlertTitle>
|
|
<AlertDescription className="flex flex-col gap-2">
|
|
{t("wizard.step3.missingStatesWarning.description")}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="w-fit"
|
|
onClick={handleRefresh}
|
|
disabled={isGenerating || isProcessing}
|
|
>
|
|
<LuRefreshCw className="mr-1.5 size-3.5" />
|
|
{t("wizard.step3.refreshExamples")}
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{!allImagesClassified && (
|
|
<div className="text-center">
|
|
<h3 className="text-lg font-medium">
|
|
{t("wizard.step3.selectImagesPrompt", {
|
|
className: currentClass,
|
|
})}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("wizard.step3.selectImagesDescription")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"rounded-lg bg-secondary/30 p-4",
|
|
isMobile && "max-h-[60vh] overflow-y-auto",
|
|
)}
|
|
>
|
|
{!unknownImages || unknownImages.length === 0 ? (
|
|
<div className="flex h-[40vh] flex-col items-center justify-center gap-4">
|
|
<p className="text-muted-foreground">
|
|
{t("wizard.step3.noImages")}
|
|
</p>
|
|
<Button onClick={generateExamples} variant="select">
|
|
{t("wizard.step3.retryGenerate")}
|
|
</Button>
|
|
</div>
|
|
) : allImagesClassified && isProcessing ? (
|
|
<div className="flex h-[40vh] flex-col items-center justify-center gap-4">
|
|
<ActivityIndicator className="size-12" />
|
|
<p className="text-lg font-medium">
|
|
{t("wizard.step3.classifying")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-6">
|
|
{unclassifiedImages.map((imageName, index) => {
|
|
const isSelected = selectedImages.has(imageName);
|
|
return (
|
|
<div
|
|
key={imageName}
|
|
className={cn(
|
|
"aspect-square cursor-pointer overflow-hidden rounded-lg border-2 bg-background transition-all",
|
|
isSelected && "border-selected ring-2 ring-selected",
|
|
)}
|
|
onClick={() => toggleImageSelection(imageName)}
|
|
>
|
|
{!loadedImages.has(imageName) && (
|
|
<div className="flex h-full items-center justify-center">
|
|
<ActivityIndicator className="size-6" />
|
|
</div>
|
|
)}
|
|
<img
|
|
src={`${baseUrl}clips/${step1Data.modelName}/train/${imageName}?t=${cacheKey}`}
|
|
alt={`Example ${index + 1}`}
|
|
className="h-full w-full object-cover"
|
|
onLoad={() => handleImageLoad(imageName)}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-[50vh] flex-col items-center justify-center gap-4">
|
|
<p className="text-sm text-destructive">
|
|
{t("wizard.step3.errors.generationFailed")}
|
|
</p>
|
|
<Button onClick={generateExamples} variant="select">
|
|
{t("wizard.step3.retryGenerate")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{!isTraining && (
|
|
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
|
|
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
|
{t("button.back", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={
|
|
allImagesClassified
|
|
? handleContinue
|
|
: handleContinueClassification
|
|
}
|
|
variant="select"
|
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
disabled={!hasGenerated || isGenerating || isProcessing}
|
|
>
|
|
{isProcessing && <ActivityIndicator className="size-4" />}
|
|
{t("button.continue", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|