diff --git a/.github/workflows/web-ui-lint.yml b/.github/workflows/web-ui-lint.yml new file mode 100644 index 000000000..298843ff5 --- /dev/null +++ b/.github/workflows/web-ui-lint.yml @@ -0,0 +1,26 @@ +name: web-ui-lint + +on: + pull_request: + paths: + - "web/gui/**" + - ".github/workflows/web-ui-lint.yml" + push: + branches: [master, Webui] + paths: + - "web/gui/**" + +jobs: + lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web/gui + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm install --no-audit --no-fund + - run: npm run lint + - run: npm run format:check diff --git a/.gitignore b/.gitignore index 0b3e5887d..638b20725 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ debug*.py config.json secrets.json *.zip +.env* +!.env.example # environments /.venv* @@ -38,3 +40,14 @@ pixi.toml train.bat debug_report.log config_diff.txt + +# Web UI +web/gui/dist/ +web/gui/release/ +web/gui/node_modules/ +web/gui/public/ui-schema.json +web/gui/src/renderer/types/generated/ +web/gui/.vite-port +web/backend/__pycache__/ +web/backend/**/__pycache__/ +web/backend/generated/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d09dac87..affd20fb6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,10 +16,25 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.17 hooks: - # Run the Ruff linter, but not the formatter. - id: ruff args: ["--fix"] types_or: [ python, pyi, jupyter ] +- repo: local + hooks: + - id: eslint + name: ESLint (frontend) + entry: python -m web.scripts.run_eslint + language: system + files: '^web/gui/.*\.(ts|tsx)$' + pass_filenames: false + + - id: check-hardcoded-dropdown-options + name: No hardcoded update("type", v)} /> + + Path + update("path", v)} + tooltip="Directory containing training images" + /> + + Prompt Source + update("balancing_strategy", v)} + /> + + + + Loss Weight + update("loss_weight", v)} /> + + Seed + update("seed", v)} /> + + ); +} diff --git a/web/gui/src/renderer/components/concepts/ConceptGrid.tsx b/web/gui/src/renderer/components/concepts/ConceptGrid.tsx new file mode 100644 index 000000000..89f3fd434 --- /dev/null +++ b/web/gui/src/renderer/components/concepts/ConceptGrid.tsx @@ -0,0 +1,123 @@ +import { FilterX, Plus, Search } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/shared"; +import type { ConceptConfig } from "@/types/generated/config"; + +import { ConceptCard } from "./ConceptCard"; + +export interface ConceptGridProps { + concepts: ConceptConfig[]; + onAdd: () => void; + onOpen: (index: number) => void; + onRemove: (index: number) => void; + onClone: (index: number) => void; + onToggle: (index: number, enabled: boolean) => void; +} + +export function ConceptGrid({ concepts, onAdd, onOpen, onRemove, onClone, onToggle }: ConceptGridProps) { + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState("ALL"); + const [showDisabled, setShowDisabled] = useState(true); + + const disabledCount = concepts.filter((c) => !c.enabled).length; + + const filtered = useMemo(() => { + return concepts + .map((c, i) => ({ concept: c, originalIndex: i })) + .filter(({ concept }) => { + if (!showDisabled && !concept.enabled) return false; + if (typeFilter !== "ALL" && concept.type !== typeFilter) return false; + if (search) { + const q = search.toLowerCase(); + const name = (concept.name || "").toLowerCase(); + const path = (concept.path || "").toLowerCase(); + if (!name.includes(q) && !path.includes(q)) return false; + } + return true; + }); + }, [concepts, search, typeFilter, showDisabled]); + + const clearFilters = () => { + setSearch(""); + setTypeFilter("ALL"); + setShowDisabled(true); + }; + const hasFilters = search !== "" || typeFilter !== "ALL" || !showDisabled; + + return ( +
+
+ + +
+ + setSearch(e.target.value)} + placeholder="Search concepts..." + className="bg-transparent text-sm text-[var(--color-on-surface)] outline-none w-48" + /> +
+ + + + + + {hasFilters && ( + + )} + + + {filtered.length} / {concepts.length} concepts + +
+ + {filtered.length > 0 ? ( +
+ {filtered.map(({ concept, originalIndex }) => ( + onOpen(originalIndex)} + onRemove={() => onRemove(originalIndex)} + onClone={() => onClone(originalIndex)} + onToggle={(enabled) => onToggle(originalIndex, enabled)} + /> + ))} +
+ ) : ( +
+

+ {concepts.length === 0 ? "No concepts yet. Add one to get started." : "No concepts match current filters."} +

+
+ )} +
+ ); +} diff --git a/web/gui/src/renderer/components/concepts/ConceptImageAugTab.tsx b/web/gui/src/renderer/components/concepts/ConceptImageAugTab.tsx new file mode 100644 index 000000000..c3e06f91f --- /dev/null +++ b/web/gui/src/renderer/components/concepts/ConceptImageAugTab.tsx @@ -0,0 +1,151 @@ +import { FormEntry, SliderEntry, Toggle } from "@/components/shared"; +import type { ConceptConfig, ConceptImageConfig } from "@/types/generated/config"; + +import { ImagePreviewPanel } from "./ImagePreviewPanel"; + +export interface ConceptImageAugTabProps { + draft: ConceptConfig; + updateImage: (field: keyof ConceptImageConfig, value: unknown) => void; +} + +interface AugRow { + label: string; + random?: keyof ConceptImageConfig; + fixed?: keyof ConceptImageConfig; + value?: keyof ConceptImageConfig; + valuePlaceholder?: string; + valueType?: "number" | "text"; + slider?: { min: number; max: number; step: number }; +} + +const AUG_ROWS: AugRow[] = [ + { label: "Crop Jitter", random: "enable_crop_jitter" }, + { label: "Random Flip", random: "enable_random_flip", fixed: "enable_fixed_flip" }, + { + label: "Random Rotation", + random: "enable_random_rotate", + fixed: "enable_fixed_rotate", + value: "random_rotate_max_angle", + valuePlaceholder: "Max angle", + valueType: "number", + slider: { min: 0, max: 360, step: 1 }, + }, + { + label: "Random Brightness", + random: "enable_random_brightness", + fixed: "enable_fixed_brightness", + value: "random_brightness_max_strength", + valuePlaceholder: "Max strength", + valueType: "number", + slider: { min: 0, max: 1, step: 0.01 }, + }, + { + label: "Random Contrast", + random: "enable_random_contrast", + fixed: "enable_fixed_contrast", + value: "random_contrast_max_strength", + valuePlaceholder: "Max strength", + valueType: "number", + slider: { min: 0, max: 1, step: 0.01 }, + }, + { + label: "Random Saturation", + random: "enable_random_saturation", + fixed: "enable_fixed_saturation", + value: "random_saturation_max_strength", + valuePlaceholder: "Max strength", + valueType: "number", + slider: { min: 0, max: 1, step: 0.01 }, + }, + { + label: "Random Hue", + random: "enable_random_hue", + fixed: "enable_fixed_hue", + value: "random_hue_max_strength", + valuePlaceholder: "Max strength", + valueType: "number", + slider: { min: 0, max: 1, step: 0.01 }, + }, + { label: "Circular Mask Generation", random: "enable_random_circular_mask_shrink" }, + { label: "Random Rotate & Crop", random: "enable_random_mask_rotate_crop" }, + { + label: "Resolution Override", + fixed: "enable_resolution_override", + value: "resolution_override", + valuePlaceholder: "e.g. 512 or 768x512", + valueType: "text", + }, +]; + +export function ConceptImageAugTab({ draft, updateImage }: ConceptImageAugTabProps) { + return ( +
+
+
+ Augmentation + Random + Fixed + Value +
+ + {AUG_ROWS.map((row) => { + const { random, fixed, value } = row; + return ( +
+ {row.label} + +
+ {random ? ( + updateImage(random, v)} /> + ) : ( + + )} +
+ +
+ {fixed ? ( + updateImage(fixed, v)} /> + ) : ( + + )} +
+ +
+ {value && row.slider ? ( + updateImage(value, v)} + min={row.slider.min} + max={row.slider.max} + step={row.slider.step} + /> + ) : value ? ( + updateImage(value, v)} + placeholder={row.valuePlaceholder} + /> + ) : ( + + )} +
+
+ ); + })} +
+ + +
+ ); +} diff --git a/web/gui/src/renderer/components/concepts/ConceptStatsPanel.tsx b/web/gui/src/renderer/components/concepts/ConceptStatsPanel.tsx new file mode 100644 index 000000000..983059c8e --- /dev/null +++ b/web/gui/src/renderer/components/concepts/ConceptStatsPanel.tsx @@ -0,0 +1,376 @@ +import { useCallback, useState } from "react"; + +import { configApi } from "@/api/configApi"; +import { Button, Tooltip } from "@/components/shared"; + +import { AspectBucketChart } from "./AspectBucketChart"; + +export interface ConceptStatsPanelProps { + conceptPath: string; + includeSubdirectories: boolean; +} + +interface StatValue { + label: string; + value: string; + tooltip?: string; +} + +/* ── Format helpers ────────────────────────────────────────── */ + +function formatPixelStat(raw: unknown): string { + if (typeof raw === "string") return "-"; + if (Array.isArray(raw)) { + const [pixels, file, resolution] = raw; + const mp = (pixels / 1_000_000).toFixed(2); + return `${mp} MP, ${resolution}\n${file}`; + } + if (typeof raw === "number") { + const mp = (raw / 1_000_000).toFixed(2); + const side = Math.round(Math.sqrt(raw)); + return `${mp} MP, ~${side}w x ${side}h`; + } + return "-"; +} + +function formatLengthStat(raw: unknown): string { + if (typeof raw === "string") return "-"; + if (Array.isArray(raw)) return `${Math.round(raw[0])} frames\n${raw[1]}`; + if (typeof raw === "number") return `${Math.round(raw)} frames`; + return "-"; +} + +function formatFpsStat(raw: unknown): string { + if (typeof raw === "string") return "-"; + if (Array.isArray(raw)) return `${Math.round(raw[0])} fps\n${raw[1]}`; + if (typeof raw === "number") return `${Math.round(raw)} fps`; + return "-"; +} + +function formatCaptionStat(raw: unknown): string { + if (typeof raw === "string") return "-"; + if (Array.isArray(raw)) { + if (raw.length >= 3) return `${raw[0]} chars, ${raw[2]} words\n${raw[1]}`; + if (raw.length >= 2) return `${Math.round(raw[0])} chars, ${Math.round(raw[1])} words`; + } + return "-"; +} + +function decimalToAspectRatio(value: number): string { + let bestNum = 1; + let bestDen = 1; + let bestError = Math.abs(value - 1); + for (let den = 1; den <= 16; den++) { + const num = Math.round(value * den); + if (num < 1) continue; + const error = Math.abs(value - num / den); + if (error < bestError) { + bestError = error; + bestNum = num; + bestDen = den; + } + } + return `${bestDen}:${bestNum}`; +} + +function computeSmallestBuckets(buckets: Record): string { + const nonZero = Object.entries(buckets) + .filter(([, v]) => v > 0) + .map(([k, v]) => ({ ratio: parseFloat(k), count: v })); + + if (nonZero.length === 0) return "-"; + + nonZero.sort((a, b) => a.count - b.count); + const minVal = nonZero[0].count; + const minVal2 = nonZero.length > 1 && nonZero[1].count > minVal ? nonZero[1].count : minVal; + + return nonZero + .filter((e) => e.count === minVal || e.count === minVal2) + .map((e) => `aspect ${decimalToAspectRatio(e.ratio)} : ${e.count} img`) + .join("\n"); +} + +/* ── Stat label with optional tooltip ──────────────────────── */ + +function StatLabel({ label, tooltip }: { label: string; tooltip?: string }) { + const el = ( + + {label} + + ); + if (tooltip) { + return {el}; + } + return el; +} + +/* ── Main component ────────────────────────────────────────── */ + +export function ConceptStatsPanel({ conceptPath, includeSubdirectories }: ConceptStatsPanelProps) { + const [stats, setStats] = useState | null>(null); + const [scanning, setScanning] = useState(false); + const [scanType, setScanType] = useState<"basic" | "advanced" | null>(null); + const [error, setError] = useState(null); + + const runScan = useCallback( + async (advanced: boolean) => { + if (!conceptPath) return; + setScanning(true); + setScanType(advanced ? "advanced" : "basic"); + setError(null); + try { + const result = await configApi.conceptStats(conceptPath, includeSubdirectories, advanced); + setStats(result); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("Stats scan failed:", err); + setError(msg); + } finally { + setScanning(false); + setScanType(null); + } + }, + [conceptPath, includeSubdirectories], + ); + + const handleCancel = useCallback(async () => { + try { + await configApi.cancelConceptStats(); + } catch (err) { + console.error("Cancel failed:", err); + } + }, []); + + const s = stats ?? {}; + + const fileSize = typeof s.file_size === "number" ? `${Math.round(s.file_size / 1_048_576)} MB` : "-"; + const processingTime = typeof s.processing_time === "number" ? `${(s.processing_time as number).toFixed(2)} s` : "-"; + + /* ── Stat definitions ────────────────────────────────────── */ + + const basicStats: StatValue[][] = [ + [ + { label: "Total Size", value: fileSize, tooltip: "Total size of all image, mask, and caption files in MB" }, + { + label: "Directories", + value: String(s.directory_count ?? "-"), + tooltip: + "Total number of directories including and under (if 'include subdirectories' is enabled) the main concept directory", + }, + ], + [ + { + label: "Total Images", + value: String(s.image_count ?? "-"), + tooltip: "Total number of image files, excluding mask and condition label files", + }, + { label: "Total Videos", value: String(s.video_count ?? "-"), tooltip: "Total number of video files" }, + { + label: "Total Masks", + value: String(s.mask_count ?? "-"), + tooltip: "Total number of mask files (ending in '-masklabel.png')", + }, + { + label: "Total Captions", + value: + s.subcaption_count && typeof s.subcaption_count === "number" && s.subcaption_count > 0 + ? `${s.caption_count} (${s.subcaption_count})` + : String(s.caption_count ?? "-"), + tooltip: + "Total number of caption files (.txt). With advanced scan, includes the total number of captions on separate lines across all files in parentheses.", + }, + ], + [ + { + label: "Images with Masks", + value: String(s.image_with_mask_count ?? "-"), + tooltip: "Total number of image files with an associated mask", + }, + { + label: "Unpaired Masks", + value: String(s.unpaired_masks ?? "-"), + tooltip: "Total number of mask files which lack a corresponding image file — if > 0, check your dataset!", + }, + ], + [ + { + label: "Images with Captions", + value: String(s.image_with_caption_count ?? "-"), + tooltip: "Total number of image files with an associated caption", + }, + { + label: "Videos with Captions", + value: String(s.video_with_caption_count ?? "-"), + tooltip: "Total number of video files with an associated caption", + }, + { + label: "Unpaired Captions", + value: String(s.unpaired_captions ?? "-"), + tooltip: + "Total number of caption files which lack a corresponding image file — if > 0, check your dataset! If using 'from file name' or 'from single text file' this can be ignored.", + }, + ], + ]; + + const advancedStats: StatValue[][] = [ + [ + { + label: "Max Pixels", + value: formatPixelStat(s.max_pixels), + tooltip: "Largest image in the concept by number of pixels (width x height)", + }, + { + label: "Avg Pixels", + value: formatPixelStat(s.avg_pixels), + tooltip: "Average size of images in the concept by number of pixels (width x height)", + }, + { + label: "Min Pixels", + value: formatPixelStat(s.min_pixels), + tooltip: "Smallest image in the concept by number of pixels (width x height)", + }, + ], + [ + { + label: "Max Length", + value: formatLengthStat(s.max_length), + tooltip: "Longest video in the concept by number of frames", + }, + { + label: "Avg Length", + value: formatLengthStat(s.avg_length), + tooltip: "Average length of videos in the concept by number of frames", + }, + { + label: "Min Length", + value: formatLengthStat(s.min_length), + tooltip: "Shortest video in the concept by number of frames", + }, + ], + [ + { label: "Max FPS", value: formatFpsStat(s.max_fps), tooltip: "Video in concept with highest fps" }, + { label: "Avg FPS", value: formatFpsStat(s.avg_fps), tooltip: "Average fps of videos in the concept" }, + { label: "Min FPS", value: formatFpsStat(s.min_fps), tooltip: "Video in concept with the lowest fps" }, + ], + [ + { + label: "Max Caption Length", + value: formatCaptionStat(s.max_caption_length), + tooltip: "Largest caption in concept by character count. For token count, assume ~2 tokens/word", + }, + { + label: "Avg Caption Length", + value: formatCaptionStat(s.avg_caption_length), + tooltip: "Average length of caption in concept by character count. For token count, assume ~2 tokens/word", + }, + { + label: "Min Caption Length", + value: formatCaptionStat(s.min_caption_length), + tooltip: "Smallest caption in concept by character count. For token count, assume ~2 tokens/word", + }, + ], + ]; + + const aspectBuckets = (s.aspect_buckets ?? {}) as Record; + const hasAspectData = Object.values(aspectBuckets).some((v) => v > 0); + const smallestBuckets = hasAspectData ? computeSmallestBuckets(aspectBuckets) : "-"; + + if (!conceptPath) { + return ( +
+

Set a concept path first to scan for statistics.

+
+ ); + } + + return ( +
+ {/* Toolbar */} +
+ + + + + {processingTime !== "-" ? `Processed in ${processingTime}` : ""} + +
+ + {/* Error banner */} + {error && ( +
+ Scan failed: {error} +
+ )} + + {stats === null ? ( +
+

+ Click {'"'}Refresh Basic{'"'} or {'"'}Refresh Advanced{'"'} to scan concept statistics. +

+
+ ) : ( +
+ {/* Basic stats */} + {basicStats.map((row, ri) => ( +
+ {row.map((stat) => ( +
+ + {stat.value} +
+ ))} +
+ ))} + + {/* Advanced stats */} + {advancedStats.some((row) => row.some((stat) => stat.value !== "-")) && ( + <> +
+ {advancedStats.map((row, ri) => ( +
+ {row.map((stat) => ( +
+ + + {stat.value} + +
+ ))} +
+ ))} + + )} + + {/* Aspect bucketing */} + {hasAspectData && ( + <> +
+
+
+ + +
+
+ + {smallestBuckets} +
+
+ + )} +
+ )} +
+ ); +} diff --git a/web/gui/src/renderer/components/concepts/ConceptTextAugTab.tsx b/web/gui/src/renderer/components/concepts/ConceptTextAugTab.tsx new file mode 100644 index 000000000..f5740c4f0 --- /dev/null +++ b/web/gui/src/renderer/components/concepts/ConceptTextAugTab.tsx @@ -0,0 +1,100 @@ +import { FormEntry, Select, Toggle } from "@/components/shared"; +import type { ConceptConfig } from "@/types/generated/config"; +import { TAG_DROPOUT_MODES, TAG_DROPOUT_SPECIAL_TAGS_MODES } from "@/types/generated/dropdownSources"; + +export interface ConceptTextAugTabProps { + draft: ConceptConfig; + updateText: (field: keyof ConceptConfig["text"], value: unknown) => void; +} + +export function ConceptTextAugTab({ draft, updateText }: ConceptTextAugTabProps) { + return ( +
+ Tag Shuffling + updateText("enable_tag_shuffling", v)} /> + + + + Tag Delimiter + updateText("tag_delimiter", v)} /> + + + + Keep Tag Count + updateText("keep_tags_count", v)} + /> + + + +
+ + Tag Dropout + updateText("tag_dropout_enable", v)} /> + + + + Dropout Mode + updateText("tag_dropout_special_tags_mode", v)} + /> + Tags + updateText("tag_dropout_special_tags", v)} + /> + + Special Tags Regex + updateText("tag_dropout_special_tags_regex", v)} + /> + + + +
+ + Randomize Caps + updateText("caps_randomize_enable", v)} /> + Force Lowercase + updateText("caps_randomize_lowercase", v)} /> + + Caps Mode + updateText("caps_randomize_mode", v)} + placeholder="capslock,title,first,random" + /> + Probability + updateText("caps_randomize_probability", v)} + /> +
+ ); +} diff --git a/web/gui/src/renderer/components/concepts/ImagePreviewPanel.tsx b/web/gui/src/renderer/components/concepts/ImagePreviewPanel.tsx new file mode 100644 index 000000000..eb99e02bb --- /dev/null +++ b/web/gui/src/renderer/components/concepts/ImagePreviewPanel.tsx @@ -0,0 +1,214 @@ +import { ChevronLeft, ChevronRight, Dice5, ImageIcon, Sparkles } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { configApi } from "@/api/configApi"; +import { Button } from "@/components/shared"; +import type { ConceptImageConfig, ConceptTextConfig } from "@/types/generated/config"; + +export interface ImagePreviewPanelProps { + conceptPath: string; + includeSubdirectories: boolean; + textConfig: ConceptTextConfig; + imageConfig?: ConceptImageConfig; +} + +interface ImageEntry { + filename: string; + path: string; + caption: string | null; +} + +export function ImagePreviewPanel({ + conceptPath, + includeSubdirectories, + textConfig, + imageConfig, +}: ImagePreviewPanelProps) { + const [images, setImages] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [loading, setLoading] = useState(false); + const [conceptCaption, setConceptCaption] = useState(null); + + const [showAugmented, setShowAugmented] = useState(false); + const [augSeed, setAugSeed] = useState(() => Math.floor(Math.random() * 2 ** 30)); + const [augBase64, setAugBase64] = useState(null); + const [augLoading, setAugLoading] = useState(false); + const augTimer = useRef>(undefined); + + useEffect(() => { + if (!conceptPath) { + setImages([]); + setCurrentIndex(0); + return; + } + + let cancelled = false; + setLoading(true); + + configApi + .conceptImages(conceptPath, includeSubdirectories) + .then((result) => { + if (cancelled) return; + setImages(result.images); + setCurrentIndex(0); + setLoading(false); + }) + .catch(() => { + if (cancelled) return; + setImages([]); + setCurrentIndex(0); + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [conceptPath, includeSubdirectories]); + + useEffect(() => { + if (textConfig.prompt_source !== "concept" || !textConfig.prompt_path) { + setConceptCaption(null); + return; + } + + let cancelled = false; + configApi + .conceptTextFile(textConfig.prompt_path) + .then((result) => { + if (!cancelled) setConceptCaption(result.content); + }) + .catch(() => { + if (!cancelled) setConceptCaption(null); + }); + + return () => { + cancelled = true; + }; + }, [textConfig.prompt_source, textConfig.prompt_path]); + + const handlePrev = useCallback(() => { + setCurrentIndex((i) => Math.max(0, i - 1)); + }, []); + + const handleNext = useCallback(() => { + setCurrentIndex((i) => Math.min(images.length - 1, i + 1)); + }, [images.length]); + + const currentImage = images[currentIndex] ?? null; + + let displayCaption = ""; + if (currentImage) { + if (textConfig.prompt_source === "filename") { + const stem = currentImage.filename.replace(/\.[^.]+$/, ""); + displayCaption = stem || "[Empty prompt]"; + } else if (textConfig.prompt_source === "concept") { + displayCaption = conceptCaption ?? "[No concept file loaded]"; + } else { + displayCaption = currentImage.caption ?? "[No caption file]"; + } + } + + const imageUrl = currentImage ? configApi.conceptImageUrl(currentImage.path) : null; + + useEffect(() => { + if (!showAugmented || !currentImage || !imageConfig) { + setAugBase64(null); + return; + } + setAugLoading(true); + if (augTimer.current) clearTimeout(augTimer.current); + augTimer.current = setTimeout(async () => { + try { + const res = await configApi.augmentationPreview({ + image_path: currentImage.path, + image: imageConfig as unknown as Record, + seed: augSeed, + }); + if (res.ok) setAugBase64(res.image_base64); + } catch { + setAugBase64(null); + } finally { + setAugLoading(false); + } + }, 250); + return () => { + if (augTimer.current) clearTimeout(augTimer.current); + }; + }, [showAugmented, currentImage, imageConfig, augSeed]); + + const rerollAugSeed = () => setAugSeed(Math.floor(Math.random() * 2 ** 30)); + + return ( +
+
+ {loading ? ( + Loading... + ) : showAugmented && augBase64 ? ( + Augmented preview + ) : imageUrl ? ( + {currentImage?.filename + ) : ( + + )} + {showAugmented && augLoading && ( + + updating… + + )} +
+ + {imageConfig && ( +
+ + {showAugmented && ( + + )} +
+ )} + +
+ + + {images.length > 0 ? `${currentIndex + 1} / ${images.length}` : "No images"} + + +
+ + {currentImage && ( +

+ {currentImage.filename} +

+ )} + +