From 9f8288f8b871ecf45e36c889814f57c7fca8f610 Mon Sep 17 00:00:00 2001 From: BitcrushedHeart <215311041+BitcrushedHeart@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:41:51 +0100 Subject: [PATCH 1/2] Introduce Electron-based webui Add an Electron + React (Vite/Tailwind) desktop frontend and a FastAPI bridge in web/, giving OneTrainer a modern GUI alongside the existing customtkinter UI without touching the training backend. - web/backend: FastAPI app with routers for config, presets, concepts, samples, sampling, training, tools (dataset/video/caption utilities), converter, tensorboard, wiki, secrets, system/GPU info and mask editor, plus WebSocket handlers for training/system/terminal streams. - web/gui: Electron main process, preload/IPC bridge, splash screen, and a React renderer with pages mirroring the desktop UI's tabs (general/model/training/lora/embedding/tools/performance/etc.), Tailwind styling, and generated TypeScript types/schemas mirrored from the Python config and enum classes. - web/scripts: generators that introspect modules/util/config and modules/util/enum to emit TypeScript types, UI schemas, and metadata, plus a hardcoded-option checker and ESLint runner, keeping the web UI in sync with the Python source of truth. - Root-level start-web-ui.bat/.sh, install.bat, and CI lint workflow to build/launch the new frontend and lint it in CI. - Bug fixes folded in: path traversal hardening in path_security.py, timestep distribution correctness, performance page and system websocket fixes, safer yt-dlp invocation (video_service.py), and an eslint globals fix for Node-context scripts. --- .github/workflows/web-ui-lint.yml | 26 + .gitignore | 13 + .pre-commit-config.yaml | 17 +- install.bat | 32 + lib.include.sh | 35 + start-web-ui.bat | 80 + start-web-ui.sh | 36 + update.bat | 35 + web/backend/main.py | 89 + web/backend/models/__init__.py | 0 web/backend/paths.py | 6 + web/backend/requirements.txt | 9 + web/backend/routers/__init__.py | 0 web/backend/routers/concepts.py | 463 ++++++ web/backend/routers/config.py | 140 ++ web/backend/routers/converter.py | 133 ++ web/backend/routers/health.py | 45 + web/backend/routers/mask_editor.py | 169 ++ web/backend/routers/presets.py | 110 ++ web/backend/routers/samples.py | 43 + web/backend/routers/sampling.py | 64 + web/backend/routers/secrets.py | 115 ++ web/backend/routers/system.py | 176 ++ web/backend/routers/tensorboard.py | 53 + web/backend/routers/tools.py | 359 ++++ web/backend/routers/training.py | 87 + web/backend/routers/video_tools.py | 128 ++ web/backend/routers/wiki.py | 238 +++ web/backend/services/__init__.py | 0 web/backend/services/_serialization.py | 32 + web/backend/services/_singleton.py | 19 + web/backend/services/concept_service.py | 39 + web/backend/services/config_service.py | 224 +++ web/backend/services/log_service.py | 111 ++ web/backend/services/monitor_service.py | 40 + web/backend/services/sampler_service.py | 246 +++ web/backend/services/tensorboard_service.py | 252 +++ web/backend/services/tool_service.py | 856 ++++++++++ web/backend/services/trainer_service.py | 312 ++++ web/backend/services/video_service.py | 683 ++++++++ web/backend/utils/__init__.py | 0 web/backend/utils/gpu_info.py | 200 +++ web/backend/utils/path_security.py | 123 ++ web/backend/ws/__init__.py | 0 web/backend/ws/connection_manager.py | 81 + web/backend/ws/system_ws.py | 35 + web/backend/ws/terminal_ws.py | 40 + web/backend/ws/training_ws.py | 32 + web/gui/.gitignore | 3 + web/gui/.prettierrc.json | 11 + web/gui/electron-builder.yml | 62 + web/gui/eslint.config.js | 51 + web/gui/index.html | 29 + web/gui/package.json | 62 + web/gui/scripts/rename-cjs.mjs | 48 + web/gui/src/main/index.ts | 698 ++++++++ web/gui/src/main/preload.ts | 31 + web/gui/src/main/splash.html | 244 +++ web/gui/src/main/splash.ts | 99 ++ web/gui/src/renderer/App.tsx | 144 ++ web/gui/src/renderer/api/configApi.ts | 164 ++ web/gui/src/renderer/api/request.ts | 66 + web/gui/src/renderer/api/samplingApi.ts | 54 + web/gui/src/renderer/api/systemApi.ts | 19 + web/gui/src/renderer/api/toolsApi.ts | 244 +++ web/gui/src/renderer/api/trainingApi.ts | 47 + web/gui/src/renderer/api/videoToolsApi.ts | 72 + .../src/renderer/components/ErrorBoundary.tsx | 124 ++ .../components/concepts/AspectBucketChart.tsx | 138 ++ .../components/concepts/ConceptCard.tsx | 141 ++ .../components/concepts/ConceptGeneralTab.tsx | 84 + .../components/concepts/ConceptGrid.tsx | 123 ++ .../concepts/ConceptImageAugTab.tsx | 151 ++ .../components/concepts/ConceptStatsPanel.tsx | 376 +++++ .../components/concepts/ConceptTextAugTab.tsx | 100 ++ .../components/concepts/ImagePreviewPanel.tsx | 214 +++ .../components/embeddings/EmbeddingCard.tsx | 64 + .../components/embeddings/EmbeddingList.tsx | 46 + .../renderer/components/layout/BottomBar.tsx | 262 +++ .../components/layout/TabNavigation.tsx | 102 ++ .../components/layout/TerminalPanel.tsx | 189 +++ .../src/renderer/components/layout/TopBar.tsx | 224 +++ .../mask-editor/MaskEditorCanvas.tsx | 405 +++++ .../components/modals/CaptionToolModal.tsx | 521 ++++++ .../components/modals/ConceptEditorModal.tsx | 112 ++ .../components/modals/ConvertModelModal.tsx | 160 ++ .../components/modals/ManualSamplingModal.tsx | 159 ++ .../components/modals/MaskToolModal.tsx | 250 +++ .../renderer/components/modals/ModalBase.tsx | 132 ++ .../components/modals/MuonAdamModal.tsx | 107 ++ .../components/modals/OffloadingModal.tsx | 46 + .../modals/OptimizerParamsModal.tsx | 136 ++ .../components/modals/ProfilingPanel.tsx | 95 ++ .../components/modals/SampleParamsModal.tsx | 102 ++ .../components/modals/SavePresetModal.tsx | 68 + .../modals/SchedulerParamsModal.tsx | 128 ++ .../modals/StandaloneSamplingModal.tsx | 316 ++++ .../components/modals/TimestepDistModal.tsx | 262 +++ .../components/modals/VideoToolModal.tsx | 515 ++++++ .../components/sampling/SampleCard.tsx | 53 + .../components/sampling/SampleList.tsx | 48 + .../components/schema/SchemaModal.tsx | 54 + .../components/schema/SchemaTabRenderer.tsx | 192 +++ .../components/shared/ArrayItemHeader.tsx | 39 + .../src/renderer/components/shared/Button.tsx | 50 + .../src/renderer/components/shared/Card.tsx | 16 + .../renderer/components/shared/DirPicker.tsx | 7 + .../components/shared/DualProgress.tsx | 28 + .../renderer/components/shared/EmptyState.tsx | 32 + .../renderer/components/shared/FilePicker.tsx | 7 + .../renderer/components/shared/FormEntry.tsx | 103 ++ .../components/shared/FormFieldWrapper.tsx | 53 + .../renderer/components/shared/IconButton.tsx | 44 + .../components/shared/LayerFilterEntry.tsx | 83 + .../renderer/components/shared/PathPicker.tsx | 82 + .../components/shared/ProgressBar.tsx | 42 + .../components/shared/ScalarChart.tsx | 200 +++ .../components/shared/SchemaField.tsx | 76 + .../components/shared/SectionCard.tsx | 25 + .../src/renderer/components/shared/Select.tsx | 94 ++ .../renderer/components/shared/Skeleton.tsx | 17 + .../components/shared/SliderEntry.tsx | 96 ++ .../renderer/components/shared/TimeEntry.tsx | 52 + .../src/renderer/components/shared/Toggle.tsx | 68 + .../renderer/components/shared/Tooltip.tsx | 217 +++ .../src/renderer/components/shared/index.ts | 21 + web/gui/src/renderer/hooks/useArrayField.ts | 49 + web/gui/src/renderer/hooks/useConfigField.ts | 21 + web/gui/src/renderer/hooks/useElapsedTime.ts | 18 + web/gui/src/renderer/hooks/useMaskEditor.ts | 184 +++ .../hooks/useReconnectingWebSocket.ts | 92 ++ .../renderer/hooks/useTerminalWebSocket.ts | 61 + web/gui/src/renderer/hooks/useToolPolling.ts | 66 + .../renderer/hooks/useTrainingWebSocket.ts | 150 ++ web/gui/src/renderer/main.tsx | 35 + web/gui/src/renderer/pages/ConceptsPage.tsx | 233 +++ web/gui/src/renderer/pages/EmbeddingsPage.tsx | 75 + web/gui/src/renderer/pages/HelpPage.tsx | 158 ++ web/gui/src/renderer/pages/MaskEditorPage.tsx | 440 +++++ .../src/renderer/pages/PerformancePage.tsx | 553 +++++++ web/gui/src/renderer/pages/RunPage.tsx | 423 +++++ web/gui/src/renderer/pages/SamplingPage.tsx | 128 ++ web/gui/src/renderer/pages/ToolsPage.tsx | 92 ++ web/gui/src/renderer/store/configStore.ts | 491 ++++++ web/gui/src/renderer/store/trainingStore.ts | 160 ++ web/gui/src/renderer/store/uiSchemaStore.ts | 53 + web/gui/src/renderer/store/uiStore.ts | 37 + web/gui/src/renderer/styles/app.css | 688 ++++++++ web/gui/src/renderer/styles/globals.css | 337 ++++ web/gui/src/renderer/styles/theme.ts | 15 + web/gui/src/renderer/types/electron.d.ts | 7 + web/gui/src/renderer/types/uiSchema.ts | 94 ++ web/gui/src/renderer/utils/chartUtils.ts | 36 + web/gui/src/renderer/utils/conditionEval.ts | 50 + web/gui/src/renderer/utils/enumLabels.ts | 1 + web/gui/src/renderer/utils/formatDuration.ts | 8 + web/gui/src/renderer/utils/inputStyles.ts | 45 + web/gui/src/renderer/utils/markdown.ts | 276 ++++ .../renderer/utils/timestepDistribution.ts | 192 +++ web/gui/src/renderer/utils/tooltips.ts | 1 + web/gui/src/renderer/vite-env.d.ts | 1 + web/gui/src/shared/brandColors.ts | 26 + web/gui/src/shared/electron-api.ts | 26 + web/gui/src/shared/ipc-channels.ts | 14 + web/gui/tsconfig.json | 26 + web/gui/tsconfig.main.json | 13 + web/gui/vite.config.ts | 59 + web/scripts/check_hardcoded_options.py | 135 ++ web/scripts/generate_types.py | 1437 +++++++++++++++++ web/scripts/generate_ui_schema.py | 642 ++++++++ web/scripts/run_eslint.py | 25 + web/scripts/run_generate_types.js | 50 + web/scripts/ui_metadata.py | 1017 ++++++++++++ web/scripts/ui_parser.py | 995 ++++++++++++ 174 files changed, 25559 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/web-ui-lint.yml create mode 100644 start-web-ui.bat create mode 100644 start-web-ui.sh create mode 100644 web/backend/main.py create mode 100644 web/backend/models/__init__.py create mode 100644 web/backend/paths.py create mode 100644 web/backend/requirements.txt create mode 100644 web/backend/routers/__init__.py create mode 100644 web/backend/routers/concepts.py create mode 100644 web/backend/routers/config.py create mode 100644 web/backend/routers/converter.py create mode 100644 web/backend/routers/health.py create mode 100644 web/backend/routers/mask_editor.py create mode 100644 web/backend/routers/presets.py create mode 100644 web/backend/routers/samples.py create mode 100644 web/backend/routers/sampling.py create mode 100644 web/backend/routers/secrets.py create mode 100644 web/backend/routers/system.py create mode 100644 web/backend/routers/tensorboard.py create mode 100644 web/backend/routers/tools.py create mode 100644 web/backend/routers/training.py create mode 100644 web/backend/routers/video_tools.py create mode 100644 web/backend/routers/wiki.py create mode 100644 web/backend/services/__init__.py create mode 100644 web/backend/services/_serialization.py create mode 100644 web/backend/services/_singleton.py create mode 100644 web/backend/services/concept_service.py create mode 100644 web/backend/services/config_service.py create mode 100644 web/backend/services/log_service.py create mode 100644 web/backend/services/monitor_service.py create mode 100644 web/backend/services/sampler_service.py create mode 100644 web/backend/services/tensorboard_service.py create mode 100644 web/backend/services/tool_service.py create mode 100644 web/backend/services/trainer_service.py create mode 100644 web/backend/services/video_service.py create mode 100644 web/backend/utils/__init__.py create mode 100644 web/backend/utils/gpu_info.py create mode 100644 web/backend/utils/path_security.py create mode 100644 web/backend/ws/__init__.py create mode 100644 web/backend/ws/connection_manager.py create mode 100644 web/backend/ws/system_ws.py create mode 100644 web/backend/ws/terminal_ws.py create mode 100644 web/backend/ws/training_ws.py create mode 100644 web/gui/.gitignore create mode 100644 web/gui/.prettierrc.json create mode 100644 web/gui/electron-builder.yml create mode 100644 web/gui/eslint.config.js create mode 100644 web/gui/index.html create mode 100644 web/gui/package.json create mode 100644 web/gui/scripts/rename-cjs.mjs create mode 100644 web/gui/src/main/index.ts create mode 100644 web/gui/src/main/preload.ts create mode 100644 web/gui/src/main/splash.html create mode 100644 web/gui/src/main/splash.ts create mode 100644 web/gui/src/renderer/App.tsx create mode 100644 web/gui/src/renderer/api/configApi.ts create mode 100644 web/gui/src/renderer/api/request.ts create mode 100644 web/gui/src/renderer/api/samplingApi.ts create mode 100644 web/gui/src/renderer/api/systemApi.ts create mode 100644 web/gui/src/renderer/api/toolsApi.ts create mode 100644 web/gui/src/renderer/api/trainingApi.ts create mode 100644 web/gui/src/renderer/api/videoToolsApi.ts create mode 100644 web/gui/src/renderer/components/ErrorBoundary.tsx create mode 100644 web/gui/src/renderer/components/concepts/AspectBucketChart.tsx create mode 100644 web/gui/src/renderer/components/concepts/ConceptCard.tsx create mode 100644 web/gui/src/renderer/components/concepts/ConceptGeneralTab.tsx create mode 100644 web/gui/src/renderer/components/concepts/ConceptGrid.tsx create mode 100644 web/gui/src/renderer/components/concepts/ConceptImageAugTab.tsx create mode 100644 web/gui/src/renderer/components/concepts/ConceptStatsPanel.tsx create mode 100644 web/gui/src/renderer/components/concepts/ConceptTextAugTab.tsx create mode 100644 web/gui/src/renderer/components/concepts/ImagePreviewPanel.tsx create mode 100644 web/gui/src/renderer/components/embeddings/EmbeddingCard.tsx create mode 100644 web/gui/src/renderer/components/embeddings/EmbeddingList.tsx create mode 100644 web/gui/src/renderer/components/layout/BottomBar.tsx create mode 100644 web/gui/src/renderer/components/layout/TabNavigation.tsx create mode 100644 web/gui/src/renderer/components/layout/TerminalPanel.tsx create mode 100644 web/gui/src/renderer/components/layout/TopBar.tsx create mode 100644 web/gui/src/renderer/components/mask-editor/MaskEditorCanvas.tsx create mode 100644 web/gui/src/renderer/components/modals/CaptionToolModal.tsx create mode 100644 web/gui/src/renderer/components/modals/ConceptEditorModal.tsx create mode 100644 web/gui/src/renderer/components/modals/ConvertModelModal.tsx create mode 100644 web/gui/src/renderer/components/modals/ManualSamplingModal.tsx create mode 100644 web/gui/src/renderer/components/modals/MaskToolModal.tsx create mode 100644 web/gui/src/renderer/components/modals/ModalBase.tsx create mode 100644 web/gui/src/renderer/components/modals/MuonAdamModal.tsx create mode 100644 web/gui/src/renderer/components/modals/OffloadingModal.tsx create mode 100644 web/gui/src/renderer/components/modals/OptimizerParamsModal.tsx create mode 100644 web/gui/src/renderer/components/modals/ProfilingPanel.tsx create mode 100644 web/gui/src/renderer/components/modals/SampleParamsModal.tsx create mode 100644 web/gui/src/renderer/components/modals/SavePresetModal.tsx create mode 100644 web/gui/src/renderer/components/modals/SchedulerParamsModal.tsx create mode 100644 web/gui/src/renderer/components/modals/StandaloneSamplingModal.tsx create mode 100644 web/gui/src/renderer/components/modals/TimestepDistModal.tsx create mode 100644 web/gui/src/renderer/components/modals/VideoToolModal.tsx create mode 100644 web/gui/src/renderer/components/sampling/SampleCard.tsx create mode 100644 web/gui/src/renderer/components/sampling/SampleList.tsx create mode 100644 web/gui/src/renderer/components/schema/SchemaModal.tsx create mode 100644 web/gui/src/renderer/components/schema/SchemaTabRenderer.tsx create mode 100644 web/gui/src/renderer/components/shared/ArrayItemHeader.tsx create mode 100644 web/gui/src/renderer/components/shared/Button.tsx create mode 100644 web/gui/src/renderer/components/shared/Card.tsx create mode 100644 web/gui/src/renderer/components/shared/DirPicker.tsx create mode 100644 web/gui/src/renderer/components/shared/DualProgress.tsx create mode 100644 web/gui/src/renderer/components/shared/EmptyState.tsx create mode 100644 web/gui/src/renderer/components/shared/FilePicker.tsx create mode 100644 web/gui/src/renderer/components/shared/FormEntry.tsx create mode 100644 web/gui/src/renderer/components/shared/FormFieldWrapper.tsx create mode 100644 web/gui/src/renderer/components/shared/IconButton.tsx create mode 100644 web/gui/src/renderer/components/shared/LayerFilterEntry.tsx create mode 100644 web/gui/src/renderer/components/shared/PathPicker.tsx create mode 100644 web/gui/src/renderer/components/shared/ProgressBar.tsx create mode 100644 web/gui/src/renderer/components/shared/ScalarChart.tsx create mode 100644 web/gui/src/renderer/components/shared/SchemaField.tsx create mode 100644 web/gui/src/renderer/components/shared/SectionCard.tsx create mode 100644 web/gui/src/renderer/components/shared/Select.tsx create mode 100644 web/gui/src/renderer/components/shared/Skeleton.tsx create mode 100644 web/gui/src/renderer/components/shared/SliderEntry.tsx create mode 100644 web/gui/src/renderer/components/shared/TimeEntry.tsx create mode 100644 web/gui/src/renderer/components/shared/Toggle.tsx create mode 100644 web/gui/src/renderer/components/shared/Tooltip.tsx create mode 100644 web/gui/src/renderer/components/shared/index.ts create mode 100644 web/gui/src/renderer/hooks/useArrayField.ts create mode 100644 web/gui/src/renderer/hooks/useConfigField.ts create mode 100644 web/gui/src/renderer/hooks/useElapsedTime.ts create mode 100644 web/gui/src/renderer/hooks/useMaskEditor.ts create mode 100644 web/gui/src/renderer/hooks/useReconnectingWebSocket.ts create mode 100644 web/gui/src/renderer/hooks/useTerminalWebSocket.ts create mode 100644 web/gui/src/renderer/hooks/useToolPolling.ts create mode 100644 web/gui/src/renderer/hooks/useTrainingWebSocket.ts create mode 100644 web/gui/src/renderer/main.tsx create mode 100644 web/gui/src/renderer/pages/ConceptsPage.tsx create mode 100644 web/gui/src/renderer/pages/EmbeddingsPage.tsx create mode 100644 web/gui/src/renderer/pages/HelpPage.tsx create mode 100644 web/gui/src/renderer/pages/MaskEditorPage.tsx create mode 100644 web/gui/src/renderer/pages/PerformancePage.tsx create mode 100644 web/gui/src/renderer/pages/RunPage.tsx create mode 100644 web/gui/src/renderer/pages/SamplingPage.tsx create mode 100644 web/gui/src/renderer/pages/ToolsPage.tsx create mode 100644 web/gui/src/renderer/store/configStore.ts create mode 100644 web/gui/src/renderer/store/trainingStore.ts create mode 100644 web/gui/src/renderer/store/uiSchemaStore.ts create mode 100644 web/gui/src/renderer/store/uiStore.ts create mode 100644 web/gui/src/renderer/styles/app.css create mode 100644 web/gui/src/renderer/styles/globals.css create mode 100644 web/gui/src/renderer/styles/theme.ts create mode 100644 web/gui/src/renderer/types/electron.d.ts create mode 100644 web/gui/src/renderer/types/uiSchema.ts create mode 100644 web/gui/src/renderer/utils/chartUtils.ts create mode 100644 web/gui/src/renderer/utils/conditionEval.ts create mode 100644 web/gui/src/renderer/utils/enumLabels.ts create mode 100644 web/gui/src/renderer/utils/formatDuration.ts create mode 100644 web/gui/src/renderer/utils/inputStyles.ts create mode 100644 web/gui/src/renderer/utils/markdown.ts create mode 100644 web/gui/src/renderer/utils/timestepDistribution.ts create mode 100644 web/gui/src/renderer/utils/tooltips.ts create mode 100644 web/gui/src/renderer/vite-env.d.ts create mode 100644 web/gui/src/shared/brandColors.ts create mode 100644 web/gui/src/shared/electron-api.ts create mode 100644 web/gui/src/shared/ipc-channels.ts create mode 100644 web/gui/tsconfig.json create mode 100644 web/gui/tsconfig.main.json create mode 100644 web/gui/vite.config.ts create mode 100644 web/scripts/check_hardcoded_options.py create mode 100644 web/scripts/generate_types.py create mode 100644 web/scripts/generate_ui_schema.py create mode 100644 web/scripts/run_eslint.py create mode 100644 web/scripts/run_generate_types.js create mode 100644 web/scripts/ui_metadata.py create mode 100644 web/scripts/ui_parser.py 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} +

+ )} + +