simplegals 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simplegals/__init__.py +1 -0
- simplegals/cli.py +112 -0
- simplegals/core/__init__.py +0 -0
- simplegals/core/config.py +89 -0
- simplegals/core/gallery.py +226 -0
- simplegals/core/metadata.py +108 -0
- simplegals/core/processor.py +77 -0
- simplegals/core/template.py +99 -0
- simplegals/template/item.html.j2 +45 -0
- simplegals/template/page.html.j2 +53 -0
- simplegals/template/style.css +97 -0
- simplegals/tui/__init__.py +38 -0
- simplegals/tui/__main__.py +3 -0
- simplegals/tui/app.py +347 -0
- simplegals/tui/file_panel.py +147 -0
- simplegals/tui/preview_panel.py +260 -0
- simplegals/tui/progress_bar.py +49 -0
- simplegals/tui/state.py +94 -0
- simplegals/workers/__init__.py +0 -0
- simplegals/workers/pool.py +58 -0
- simplegals/workers/progress.py +73 -0
- simplegals-0.1.0.dist-info/METADATA +139 -0
- simplegals-0.1.0.dist-info/RECORD +25 -0
- simplegals-0.1.0.dist-info/WHEEL +4 -0
- simplegals-0.1.0.dist-info/entry_points.txt +3 -0
simplegals/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
simplegals/cli.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .core.config import init_project, load_project_config
|
|
8
|
+
from .core.gallery import build, clean, validate
|
|
9
|
+
from .workers.progress import ProgressState, format_cli_progress
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _resolve_config(args: argparse.Namespace) -> Path:
|
|
13
|
+
return Path(args.config) if args.config else Path.cwd() / "simpleGal.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
17
|
+
config_path = Path(args.config) if args.config else None
|
|
18
|
+
result = init_project(Path.cwd(), config_path=config_path)
|
|
19
|
+
print(f"Initialized: {result}")
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
24
|
+
config_path = _resolve_config(args)
|
|
25
|
+
try:
|
|
26
|
+
config = load_project_config(config_path)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
print(f"Config not found: {config_path}. Run 'simpleGals init' first.", file=sys.stderr)
|
|
29
|
+
return 1
|
|
30
|
+
errors = validate(Path.cwd(), config)
|
|
31
|
+
if errors:
|
|
32
|
+
for e in errors:
|
|
33
|
+
print(f" error: {e}", file=sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
print("Validation passed.")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
40
|
+
config_path = _resolve_config(args)
|
|
41
|
+
try:
|
|
42
|
+
config = load_project_config(config_path)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
print(f"Config not found: {config_path}. Run 'simpleGals init' first.", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
_first = [True]
|
|
48
|
+
|
|
49
|
+
def _on_progress(state: ProgressState) -> None:
|
|
50
|
+
text = format_cli_progress(state)
|
|
51
|
+
if _first[0]:
|
|
52
|
+
sys.stderr.write(text + "\n")
|
|
53
|
+
_first[0] = False
|
|
54
|
+
else:
|
|
55
|
+
sys.stderr.write(f"\x1b[2A\r{text}\n")
|
|
56
|
+
sys.stderr.flush()
|
|
57
|
+
|
|
58
|
+
log_path, had_errors = build(Path.cwd(), config, progress_callback=_on_progress, force=args.force)
|
|
59
|
+
|
|
60
|
+
if had_errors:
|
|
61
|
+
print(f"\nBuild completed with errors. Log: {log_path}", file=sys.stderr)
|
|
62
|
+
lines = log_path.read_text(encoding="utf-8").splitlines()
|
|
63
|
+
tail = lines[-20:] if len(lines) > 20 else lines
|
|
64
|
+
print("\n--- last 20 lines of build log ---", file=sys.stderr)
|
|
65
|
+
print("\n".join(tail), file=sys.stderr)
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
print(f"Build complete. Log: {log_path}")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_clean(args: argparse.Namespace) -> int:
|
|
73
|
+
clean(Path.cwd())
|
|
74
|
+
print("Cleaned .meta/")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
parser = argparse.ArgumentParser(
|
|
80
|
+
prog="simpleGals",
|
|
81
|
+
description="Static HTML image gallery generator",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"-c", "--config",
|
|
85
|
+
metavar="PATH",
|
|
86
|
+
help="Path to simpleGal.json (default: ./simpleGal.json)",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
90
|
+
sub.add_parser("init", help="Create a stub simpleGal.json in the current directory")
|
|
91
|
+
sub.add_parser("validate", help="Validate config and input images")
|
|
92
|
+
build_parser = sub.add_parser("build", help="Build the gallery")
|
|
93
|
+
build_parser.add_argument(
|
|
94
|
+
"--force", "-f",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="Rebuild all output files, ignoring the cache",
|
|
97
|
+
)
|
|
98
|
+
sub.add_parser("clean", help="Remove all cached metadata from .meta/")
|
|
99
|
+
|
|
100
|
+
args = parser.parse_args()
|
|
101
|
+
|
|
102
|
+
handlers = {
|
|
103
|
+
"init": cmd_init,
|
|
104
|
+
"validate": cmd_validate,
|
|
105
|
+
"build": cmd_build,
|
|
106
|
+
"clean": cmd_clean,
|
|
107
|
+
}
|
|
108
|
+
sys.exit(handlers[args.command](args))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class GlobalConfig:
|
|
12
|
+
file_panel_width: int | str = 30
|
|
13
|
+
scroll_rate: float = 2.0
|
|
14
|
+
preview_delay: int = 125
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Layout:
|
|
19
|
+
columns: int = 4
|
|
20
|
+
rows: int = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ProjectConfig:
|
|
25
|
+
title: str = "Gallery"
|
|
26
|
+
description: str = ""
|
|
27
|
+
layout: Layout = field(default_factory=Layout)
|
|
28
|
+
quality: int = 90
|
|
29
|
+
copyright: str = ""
|
|
30
|
+
template: str | None = None
|
|
31
|
+
images: dict = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def global_config_path() -> Path:
|
|
35
|
+
system = platform.system()
|
|
36
|
+
if system == "Darwin":
|
|
37
|
+
return Path.home() / "Library" / "Application Support" / "simplegals" / "config.json"
|
|
38
|
+
if system == "Windows":
|
|
39
|
+
import os
|
|
40
|
+
appdata = os.environ.get("APPDATA", str(Path.home()))
|
|
41
|
+
return Path(appdata) / "simplegals" / "config.json"
|
|
42
|
+
import os
|
|
43
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
|
44
|
+
return Path(xdg) / "simplegals" / "config.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_global_config(path: Path | None = None) -> GlobalConfig:
|
|
48
|
+
p = path or global_config_path()
|
|
49
|
+
if not p.exists():
|
|
50
|
+
return GlobalConfig()
|
|
51
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
52
|
+
return GlobalConfig(**{k: v for k, v in data.items() if k in GlobalConfig.__dataclass_fields__})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def save_global_config(config: GlobalConfig, path: Path | None = None) -> None:
|
|
56
|
+
p = path or global_config_path()
|
|
57
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
p.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_project_config(path: Path) -> ProjectConfig:
|
|
62
|
+
if not path.exists():
|
|
63
|
+
raise FileNotFoundError(f"Config not found: {path}")
|
|
64
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
65
|
+
layout_data = data.pop("layout", {})
|
|
66
|
+
layout = Layout(**{k: v for k, v in layout_data.items() if k in Layout.__dataclass_fields__})
|
|
67
|
+
fields = {k: v for k, v in data.items() if k in ProjectConfig.__dataclass_fields__}
|
|
68
|
+
fields["layout"] = layout
|
|
69
|
+
return ProjectConfig(**fields)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_project_config(config: ProjectConfig, path: Path) -> None:
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
path.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def init_project(project_dir: Path, config_path: Path | None = None) -> Path:
|
|
78
|
+
target = config_path or (project_dir / "simpleGal.json")
|
|
79
|
+
if not target.exists():
|
|
80
|
+
save_project_config(ProjectConfig(), target)
|
|
81
|
+
return target
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def settings_hash(config: ProjectConfig) -> str:
|
|
85
|
+
data = json.dumps(
|
|
86
|
+
{"copyright": config.copyright, "quality": config.quality, "template": config.template},
|
|
87
|
+
sort_keys=True,
|
|
88
|
+
)
|
|
89
|
+
return hashlib.sha256(data.encode()).hexdigest()[:16]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import multiprocessing
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import bitmath
|
|
11
|
+
|
|
12
|
+
from .config import ProjectConfig, settings_hash
|
|
13
|
+
from .metadata import (
|
|
14
|
+
ImageSidecar,
|
|
15
|
+
OutputMeta,
|
|
16
|
+
ThumbMeta,
|
|
17
|
+
check_staleness,
|
|
18
|
+
file_sha256,
|
|
19
|
+
load_sidecar,
|
|
20
|
+
now_rfc3339,
|
|
21
|
+
save_sidecar,
|
|
22
|
+
)
|
|
23
|
+
from .template import render_gallery
|
|
24
|
+
from ..workers.pool import dispatch
|
|
25
|
+
from ..workers.progress import ProgressState, drain_queue, format_cli_progress
|
|
26
|
+
|
|
27
|
+
SUPPORTED_EXTENSIONS: frozenset[str] = frozenset({".jpg", ".jpeg", ".png"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ensure_project_dirs(project_dir: Path) -> tuple[Path, Path, Path]:
|
|
31
|
+
in_dir = project_dir / "in"
|
|
32
|
+
out_dir = project_dir / "out"
|
|
33
|
+
meta_dir = project_dir / ".meta"
|
|
34
|
+
for d in (in_dir, out_dir, meta_dir):
|
|
35
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
return in_dir, out_dir, meta_dir
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def scan_sources(in_dir: Path) -> list[Path]:
|
|
40
|
+
return sorted(
|
|
41
|
+
p for p in in_dir.iterdir()
|
|
42
|
+
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate(project_dir: Path, config: ProjectConfig) -> list[str]:
|
|
47
|
+
errors: list[str] = []
|
|
48
|
+
in_dir = project_dir / "in"
|
|
49
|
+
if not in_dir.exists():
|
|
50
|
+
errors.append(f"Missing input directory: {in_dir}")
|
|
51
|
+
return errors
|
|
52
|
+
source_names = {p.name for p in scan_sources(in_dir)}
|
|
53
|
+
for name in config.images:
|
|
54
|
+
if name not in source_names:
|
|
55
|
+
errors.append(f"Image in config not found in in/: {name}")
|
|
56
|
+
return errors
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def clean(project_dir: Path) -> None:
|
|
60
|
+
meta_dir = project_dir / ".meta"
|
|
61
|
+
if meta_dir.exists():
|
|
62
|
+
for p in meta_dir.iterdir():
|
|
63
|
+
if p.is_file():
|
|
64
|
+
p.unlink()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def prune_removed_sources(
|
|
68
|
+
out_dir: Path,
|
|
69
|
+
meta_dir: Path,
|
|
70
|
+
source_names: set[str],
|
|
71
|
+
) -> int:
|
|
72
|
+
"""Delete out/ and .meta/ artifacts for sources no longer present in in/.
|
|
73
|
+
|
|
74
|
+
Returns the number of source entries pruned.
|
|
75
|
+
"""
|
|
76
|
+
removed = 0
|
|
77
|
+
for sidecar in meta_dir.glob("*.json"):
|
|
78
|
+
name = sidecar.stem # e.g. "DSC_8297.jpg" (strip trailing .json)
|
|
79
|
+
if name in source_names:
|
|
80
|
+
continue
|
|
81
|
+
stem = Path(name).stem
|
|
82
|
+
ext = Path(name).suffix
|
|
83
|
+
sidecar.unlink(missing_ok=True)
|
|
84
|
+
(meta_dir / f"{stem}_thumb{ext}").unlink(missing_ok=True)
|
|
85
|
+
(out_dir / name).unlink(missing_ok=True)
|
|
86
|
+
(out_dir / f"{stem}_thumb{ext}").unlink(missing_ok=True)
|
|
87
|
+
(out_dir / f"{stem}_item.html").unlink(missing_ok=True)
|
|
88
|
+
removed += 1
|
|
89
|
+
return removed
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build(
|
|
93
|
+
project_dir: Path,
|
|
94
|
+
config: ProjectConfig,
|
|
95
|
+
progress_callback=None,
|
|
96
|
+
force: bool = False,
|
|
97
|
+
) -> tuple[Path, bool]:
|
|
98
|
+
"""Run a full gallery build. Returns (log_path, had_errors)."""
|
|
99
|
+
in_dir, out_dir, meta_dir = ensure_project_dirs(project_dir)
|
|
100
|
+
|
|
101
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
102
|
+
log_path = meta_dir / f"build-{ts}.log"
|
|
103
|
+
log_lines: list[str] = []
|
|
104
|
+
|
|
105
|
+
def log(msg: str) -> None:
|
|
106
|
+
log_lines.append(msg)
|
|
107
|
+
|
|
108
|
+
sources = scan_sources(in_dir)
|
|
109
|
+
log(f"Found {len(sources)} source image(s) in {in_dir}")
|
|
110
|
+
|
|
111
|
+
source_names = {s.name for s in sources}
|
|
112
|
+
pruned = prune_removed_sources(out_dir, meta_dir, source_names)
|
|
113
|
+
if pruned:
|
|
114
|
+
log(f"Pruned {pruned} removed source(s) from out/ and .meta/")
|
|
115
|
+
|
|
116
|
+
if force:
|
|
117
|
+
log("Force rebuild requested — skipping cache.")
|
|
118
|
+
|
|
119
|
+
thumb_tasks: list[tuple] = []
|
|
120
|
+
output_tasks: list[tuple] = []
|
|
121
|
+
|
|
122
|
+
for source in sources:
|
|
123
|
+
if force:
|
|
124
|
+
thumb_stale, output_stale = True, True
|
|
125
|
+
else:
|
|
126
|
+
thumb_stale, output_stale = check_staleness(source, meta_dir, config)
|
|
127
|
+
if thumb_stale:
|
|
128
|
+
thumb_tasks.append((str(source), str(meta_dir), None))
|
|
129
|
+
if output_stale:
|
|
130
|
+
output_tasks.append((str(source), str(out_dir), {
|
|
131
|
+
"quality": config.quality,
|
|
132
|
+
"copyright": config.copyright,
|
|
133
|
+
"template": config.template,
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
log(f"Tasks: {len(thumb_tasks)} thumb, {len(output_tasks)} output")
|
|
137
|
+
|
|
138
|
+
had_errors = False
|
|
139
|
+
if thumb_tasks or output_tasks:
|
|
140
|
+
q: multiprocessing.Queue = multiprocessing.Queue()
|
|
141
|
+
state = ProgressState(
|
|
142
|
+
thumb_total=len(thumb_tasks),
|
|
143
|
+
output_total=len(output_tasks),
|
|
144
|
+
)
|
|
145
|
+
_done = threading.Event()
|
|
146
|
+
|
|
147
|
+
def _run_dispatch() -> None:
|
|
148
|
+
dispatch(thumb_tasks, output_tasks, q)
|
|
149
|
+
_done.set()
|
|
150
|
+
|
|
151
|
+
dispatch_thread = threading.Thread(target=_run_dispatch, daemon=True)
|
|
152
|
+
dispatch_thread.start()
|
|
153
|
+
|
|
154
|
+
while not _done.wait(timeout=0.05):
|
|
155
|
+
state = drain_queue(q, state, timeout=0.02)
|
|
156
|
+
if progress_callback:
|
|
157
|
+
progress_callback(state)
|
|
158
|
+
|
|
159
|
+
# final drain after all workers finish
|
|
160
|
+
state = drain_queue(q, state, timeout=0.1)
|
|
161
|
+
if progress_callback:
|
|
162
|
+
progress_callback(state)
|
|
163
|
+
|
|
164
|
+
dispatch_thread.join()
|
|
165
|
+
log(format_cli_progress(state))
|
|
166
|
+
if state.thumb_failed or state.output_failed:
|
|
167
|
+
had_errors = True
|
|
168
|
+
log(f"Errors: {state.thumb_failed} thumb, {state.output_failed} output")
|
|
169
|
+
|
|
170
|
+
s_hash = settings_hash(config)
|
|
171
|
+
for source in sources:
|
|
172
|
+
sidecar = load_sidecar(meta_dir, source.name) or ImageSidecar(
|
|
173
|
+
source=source.name,
|
|
174
|
+
mtime=now_rfc3339(),
|
|
175
|
+
sha256=file_sha256(source),
|
|
176
|
+
settings_hash=s_hash,
|
|
177
|
+
)
|
|
178
|
+
thumb_path = meta_dir / f"{source.stem}_thumb{source.suffix}"
|
|
179
|
+
if thumb_path.exists():
|
|
180
|
+
sidecar.thumb = ThumbMeta(path=str(thumb_path), generated_at=now_rfc3339())
|
|
181
|
+
output_path = out_dir / source.name
|
|
182
|
+
if output_path.exists():
|
|
183
|
+
sidecar.output = OutputMeta(
|
|
184
|
+
path=str(output_path),
|
|
185
|
+
thumb_path=str(out_dir / f"{source.stem}_thumb{source.suffix}"),
|
|
186
|
+
generated_at=now_rfc3339(),
|
|
187
|
+
)
|
|
188
|
+
sidecar.settings_hash = s_hash
|
|
189
|
+
save_sidecar(meta_dir, sidecar)
|
|
190
|
+
|
|
191
|
+
raw_records = []
|
|
192
|
+
for source in sources:
|
|
193
|
+
img_config = config.images.get(source.name, {})
|
|
194
|
+
size_str = bitmath.getsize(str(source), bestprefix=True).format("{value:.2f} {unit}")
|
|
195
|
+
mtime_dt = datetime.fromtimestamp(source.stat().st_mtime, tz=timezone.utc)
|
|
196
|
+
raw_records.append({
|
|
197
|
+
"filename": source.name,
|
|
198
|
+
"output_path": source.name,
|
|
199
|
+
"thumb_path": f"{source.stem}_thumb{source.suffix}",
|
|
200
|
+
"caption": img_config.get("caption", ""),
|
|
201
|
+
"alt": img_config.get("alt", ""),
|
|
202
|
+
"include": img_config.get("include", True),
|
|
203
|
+
"date": mtime_dt.strftime("%Y-%m-%d"),
|
|
204
|
+
"size": size_str,
|
|
205
|
+
"item_page": f"{source.stem}_item.html",
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
log("Rendering HTML templates...")
|
|
209
|
+
render_gallery(out_dir, config, raw_records)
|
|
210
|
+
log("Build complete.")
|
|
211
|
+
|
|
212
|
+
log_path.write_text("\n".join(log_lines) + "\n", encoding="utf-8")
|
|
213
|
+
|
|
214
|
+
jsonl_path = meta_dir / "build.jsonl"
|
|
215
|
+
entry = {
|
|
216
|
+
"ts": now_rfc3339(),
|
|
217
|
+
"sources": len(sources),
|
|
218
|
+
"thumb_tasks": len(thumb_tasks),
|
|
219
|
+
"output_tasks": len(output_tasks),
|
|
220
|
+
"had_errors": had_errors,
|
|
221
|
+
"log": str(log_path),
|
|
222
|
+
}
|
|
223
|
+
with open(jsonl_path, "a", encoding="utf-8") as f:
|
|
224
|
+
f.write(json.dumps(entry) + "\n")
|
|
225
|
+
|
|
226
|
+
return log_path, had_errors
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .config import ProjectConfig, settings_hash as compute_settings_hash
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ThumbMeta:
|
|
14
|
+
path: str
|
|
15
|
+
generated_at: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class OutputMeta:
|
|
20
|
+
path: str
|
|
21
|
+
thumb_path: str
|
|
22
|
+
generated_at: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ImageSidecar:
|
|
27
|
+
source: str
|
|
28
|
+
mtime: str
|
|
29
|
+
sha256: str
|
|
30
|
+
settings_hash: str
|
|
31
|
+
thumb: ThumbMeta | None = None
|
|
32
|
+
output: OutputMeta | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sidecar_path(meta_dir: Path, source_name: str) -> Path:
|
|
36
|
+
return meta_dir / f"{source_name}.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_sidecar(meta_dir: Path, source_name: str) -> ImageSidecar | None:
|
|
40
|
+
p = sidecar_path(meta_dir, source_name)
|
|
41
|
+
if not p.exists():
|
|
42
|
+
return None
|
|
43
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
44
|
+
thumb = ThumbMeta(**data["thumb"]) if data.get("thumb") else None
|
|
45
|
+
output = OutputMeta(**data["output"]) if data.get("output") else None
|
|
46
|
+
return ImageSidecar(
|
|
47
|
+
source=data["source"],
|
|
48
|
+
mtime=data["mtime"],
|
|
49
|
+
sha256=data["sha256"],
|
|
50
|
+
settings_hash=data["settings_hash"],
|
|
51
|
+
thumb=thumb,
|
|
52
|
+
output=output,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save_sidecar(meta_dir: Path, sidecar: ImageSidecar) -> None:
|
|
57
|
+
meta_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
p = sidecar_path(meta_dir, sidecar.source)
|
|
59
|
+
p.write_text(json.dumps(asdict(sidecar), indent=2), encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def file_sha256(path: Path) -> str:
|
|
63
|
+
h = hashlib.sha256()
|
|
64
|
+
with open(path, "rb") as f:
|
|
65
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
66
|
+
h.update(chunk)
|
|
67
|
+
return h.hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def now_rfc3339() -> str:
|
|
71
|
+
return datetime.now(timezone.utc).isoformat()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _mtime_str(path: Path) -> str:
|
|
75
|
+
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_staleness(
|
|
79
|
+
source: Path,
|
|
80
|
+
meta_dir: Path,
|
|
81
|
+
config: ProjectConfig,
|
|
82
|
+
) -> tuple[bool, bool]:
|
|
83
|
+
"""Return (thumb_needs_regen, output_needs_regen)."""
|
|
84
|
+
sidecar = load_sidecar(meta_dir, source.name)
|
|
85
|
+
if sidecar is None:
|
|
86
|
+
return True, True
|
|
87
|
+
|
|
88
|
+
current_s_hash = compute_settings_hash(config)
|
|
89
|
+
current_mtime = _mtime_str(source)
|
|
90
|
+
|
|
91
|
+
def _artifacts_exist() -> tuple[bool, bool]:
|
|
92
|
+
thumb_missing = sidecar.thumb is None or not Path(sidecar.thumb.path).exists()
|
|
93
|
+
output_missing = sidecar.output is None or not Path(sidecar.output.path).exists()
|
|
94
|
+
return thumb_missing, output_missing
|
|
95
|
+
|
|
96
|
+
if current_mtime == sidecar.mtime:
|
|
97
|
+
thumb_missing, output_missing = _artifacts_exist()
|
|
98
|
+
return thumb_missing, output_missing or current_s_hash != sidecar.settings_hash
|
|
99
|
+
|
|
100
|
+
current_sha = file_sha256(source)
|
|
101
|
+
if current_sha == sidecar.sha256:
|
|
102
|
+
# touched but unchanged — refresh stored mtime
|
|
103
|
+
sidecar.mtime = current_mtime
|
|
104
|
+
save_sidecar(meta_dir, sidecar)
|
|
105
|
+
thumb_missing, output_missing = _artifacts_exist()
|
|
106
|
+
return thumb_missing, output_missing or current_s_hash != sidecar.settings_hash
|
|
107
|
+
|
|
108
|
+
return True, True
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import piexif
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from .config import ProjectConfig
|
|
9
|
+
|
|
10
|
+
SGUI_THUMB_MAX: tuple[int, int] = (300, 300)
|
|
11
|
+
HTML_THUMB_MAX: tuple[int, int] = (600, 450)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _thumb_name(source: Path) -> str:
|
|
15
|
+
return f"{source.stem}_thumb{source.suffix}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def generate_sgui_thumb(source: Path, meta_dir: Path) -> Path:
|
|
19
|
+
"""Generate .meta/<stem>_thumb<ext> for sgui preview. Not subject to publishing settings."""
|
|
20
|
+
dest = meta_dir / _thumb_name(source)
|
|
21
|
+
with Image.open(source) as img:
|
|
22
|
+
img = img.copy()
|
|
23
|
+
img.thumbnail(SGUI_THUMB_MAX, Image.LANCZOS)
|
|
24
|
+
img.save(dest)
|
|
25
|
+
return dest
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generate_output(
|
|
29
|
+
source: Path,
|
|
30
|
+
out_dir: Path,
|
|
31
|
+
config: ProjectConfig,
|
|
32
|
+
) -> tuple[Path, Path]:
|
|
33
|
+
"""Generate out/<filename>.<ext> and out/<stem>_thumb<ext>. Returns (output_path, thumb_path)."""
|
|
34
|
+
output_path = out_dir / source.name
|
|
35
|
+
thumb_path = out_dir / _thumb_name(source)
|
|
36
|
+
|
|
37
|
+
with Image.open(source) as img:
|
|
38
|
+
img = img.copy()
|
|
39
|
+
|
|
40
|
+
if config.copyright:
|
|
41
|
+
img = _inject_copyright(img, config.copyright, source)
|
|
42
|
+
|
|
43
|
+
save_kwargs: dict = {"quality": config.quality}
|
|
44
|
+
img.save(output_path, **_format_save_kwargs(source, save_kwargs))
|
|
45
|
+
|
|
46
|
+
img.thumbnail(HTML_THUMB_MAX, Image.LANCZOS)
|
|
47
|
+
img.save(thumb_path, **_format_save_kwargs(source, save_kwargs))
|
|
48
|
+
|
|
49
|
+
return output_path, thumb_path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_save_kwargs(source: Path, kwargs: dict) -> dict:
|
|
53
|
+
ext = source.suffix.lower()
|
|
54
|
+
if ext in (".jpg", ".jpeg"):
|
|
55
|
+
return kwargs
|
|
56
|
+
if ext == ".png":
|
|
57
|
+
# Pillow maps quality 0-100 → compress_level 0-9
|
|
58
|
+
compress = max(0, min(9, round((100 - kwargs.get("quality", 90)) / 11)))
|
|
59
|
+
return {"compress_level": compress}
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _inject_copyright(img: Image.Image, copyright: str, source: Path) -> Image.Image:
|
|
64
|
+
ext = source.suffix.lower()
|
|
65
|
+
if ext not in (".jpg", ".jpeg"):
|
|
66
|
+
return img
|
|
67
|
+
try:
|
|
68
|
+
exif_bytes = img.info.get("exif", b"")
|
|
69
|
+
if exif_bytes:
|
|
70
|
+
exif_dict = piexif.load(exif_bytes)
|
|
71
|
+
else:
|
|
72
|
+
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}}
|
|
73
|
+
exif_dict["0th"][piexif.ImageIFD.Copyright] = copyright.encode("utf-8")
|
|
74
|
+
img.info["exif"] = piexif.dump(exif_dict)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
return img
|