mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 19:28:57 +03:00
* migrate web to eslint 10 flat config ESLint 10 dropped `.eslintrc` support, so `.eslintrc.cjs` is replaced with `eslint.config.js` and the lint scripts no longer pass `--ext` or `--ignore-path`. typescript-eslint moves to 8, react-hooks to 7, and react-refresh to 0.5, and the unused jest and vitest-globals plugins are removed. Lint behaves as it did before: catch variables aren't checked, unused disable directives aren't reported, and rules newly added to the recommended sets are off until the code passes them. typescript-eslint 8 flags constants used only in `typeof`, so those are now exported, or replaced with a union type where the export would trip react-refresh. * fix lint findings from the eslint 10 recommended rules Remove the rule overrides from the flat config migration and fix what they were hiding. Unused catch bindings are dropped, 20 disable directives that suppressed nothing are removed (react-hooks 5.2 and 7.1.1 report identical exhaustive-deps findings with inline config ignored), dead initial values are dropped, short-circuit calls become if statements or optional calls, rethrown errors pass `cause`, and the disabled "No recordings" tooltip in `ReviewTimeline` is removed along with its memo and the `getRecordingAvailability` prop. The 3 react-refresh warnings for files that export contexts or classes are left for a later refactor.
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 {
|
|
// 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>
|
|
);
|
|
}
|