Refactor HVideoTool to support project-based workflow: introduced project management features, updated UI for project handling, and enhanced documentation in README and CLAUDE.md. The tool now organizes images and settings into projects, improving usability and detection caching.

This commit is contained in:
Leonid Pershin
2026-06-07 04:53:27 +03:00
parent 7f0121b7df
commit e27dfdf518
27 changed files with 2998 additions and 387 deletions
+148
View File
@@ -0,0 +1,148 @@
"""A HVideoTool *project*: a self-contained folder on disk.
This replaces the old "open a bare folder of images" model. A project is a folder
that holds:
```
MyProject/
├── project.json # version, name, created, source, settings{detector/model/threshold/restore}
├── frames/ # the images (what used to be "the folder")
├── detections.json # the detection cache (basename -> detections, tagged with detector key)
└── collections/ # curation sub-folders (created lazily)
```
The project file carries the **per-project** settings (detector, model, overlay
threshold, restore engine). Global ``settings.json`` only seeds the defaults for
*new* projects; once a project exists it remembers how it was last inspected.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from ..config import AppConfig
PROJECT_FILE = "project.json"
FRAMES_DIR = "frames"
CACHE_FILE = "detections.json"
COLLECTIONS_DIR = "collections"
FAVORITES_DIR = "Избранное" # the single default collection ("в избранное")
_VERSION = 1
# The subset of AppConfig fields a project remembers (mirrored to/from project.json).
_SETTING_KEYS = (
"detector",
"model_path",
"default_threshold",
"restorer",
"dm_dir",
"dm_model",
"dm_python",
"dm_gpu",
)
@dataclass
class Project:
"""A HVideoTool project rooted at ``root`` — the single source of truth for its
on-disk layout and its per-project settings."""
root: Path
name: str
settings: dict = field(default_factory=dict) # subset of AppConfig fields
source: str | None = None # originating video/folder, if any
created: str | None = None
# ----------------------------------------------------------------- paths
@property
def project_file(self) -> Path:
return self.root / PROJECT_FILE
@property
def frames_dir(self) -> Path:
return self.root / FRAMES_DIR
@property
def cache_path(self) -> Path:
return self.root / CACHE_FILE
@property
def collections_dir(self) -> Path:
return self.root / COLLECTIONS_DIR
@property
def favorites_dir(self) -> Path:
"""The single default collection — frames moved "to favorites" land here."""
return self.collections_dir / FAVORITES_DIR
# ------------------------------------------------------------- lifecycle
@classmethod
def create(
cls,
root: Path | str,
name: str | None = None,
settings: dict | None = None,
source: str | None = None,
) -> "Project":
"""Create a new project folder (with ``frames/``) and write ``project.json``."""
root = Path(root)
proj = cls(
root=root,
name=name or root.name,
settings={k: v for k, v in (settings or {}).items() if k in _SETTING_KEYS},
source=source,
created=datetime.now().isoformat(timespec="seconds"),
)
proj.frames_dir.mkdir(parents=True, exist_ok=True)
proj.save()
return proj
@classmethod
def load(cls, path: Path | str) -> "Project":
"""Load a project from its folder or directly from its ``project.json``."""
path = Path(path)
root = path.parent if path.name == PROJECT_FILE else path
data = json.loads((root / PROJECT_FILE).read_text(encoding="utf-8"))
return cls(
root=root,
name=data.get("name", root.name),
settings={k: v for k, v in data.get("settings", {}).items() if k in _SETTING_KEYS},
source=data.get("source"),
created=data.get("created"),
)
@staticmethod
def is_project(path: Path | str) -> bool:
"""True if ``path`` is a project folder (or a ``project.json``)."""
path = Path(path)
if path.name == PROJECT_FILE:
return path.is_file()
return (path / PROJECT_FILE).is_file()
def save(self) -> None:
"""Write ``project.json``."""
payload = {
"version": _VERSION,
"name": self.name,
"created": self.created,
"source": self.source,
"settings": self.settings,
}
self.root.mkdir(parents=True, exist_ok=True)
self.project_file.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
# ----------------------------------------------------- settings <-> config
def apply_to_config(self, cfg: AppConfig) -> None:
"""Overlay this project's stored settings onto ``cfg`` (mutates it)."""
for key in _SETTING_KEYS:
if key in self.settings:
setattr(cfg, key, self.settings[key])
def update_from_config(self, cfg: AppConfig) -> None:
"""Capture the per-project settings from ``cfg`` into ``self.settings``."""
self.settings = {key: getattr(cfg, key) for key in _SETTING_KEYS}