PhotoWall 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.
- photowall/__init__.py +4 -0
- photowall/assets.py +633 -0
- photowall/cli.py +67 -0
- photowall/index.html +4122 -0
- photowall/index.py +222 -0
- photowall/tags.py +1095 -0
- photowall-0.1.0.dist-info/METADATA +43 -0
- photowall-0.1.0.dist-info/RECORD +11 -0
- photowall-0.1.0.dist-info/WHEEL +4 -0
- photowall-0.1.0.dist-info/entry_points.txt +2 -0
- photowall-0.1.0.dist-info/licenses/LICENSE +21 -0
photowall/__init__.py
ADDED
photowall/assets.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""
|
|
2
|
+
assets.py - 照片墙元数据生成脚本
|
|
3
|
+
|
|
4
|
+
使用方法:
|
|
5
|
+
photowall assets # 使用默认标题 "PhotoWall"
|
|
6
|
+
photowall assets --title "我的二次元画廊" # 指定自定义相册标题
|
|
7
|
+
|
|
8
|
+
功能说明:
|
|
9
|
+
1. 自动扫描 assets/ 目录下的所有图片与视频,无条件全量生成 assets.js。
|
|
10
|
+
2. 校验 tags.json:
|
|
11
|
+
- 若 tags.json 不存在,输出显眼警告提示用户先运行 photowall tags;
|
|
12
|
+
- 若某张图片的文件大小或修改时间与 tags.json 不一致,输出该文件的 FullName 警告。
|
|
13
|
+
3. 解析每张图片的真实尺寸,并自动根据 EXIF Orientation 校正宽高旋转。
|
|
14
|
+
4. 自动提取 MP4 视频时长。
|
|
15
|
+
5. 全路径 URL 安全编码 (urllib.parse.quote),避免特殊字符与空格导致裂图。
|
|
16
|
+
6. 融合目录分词标签、画幅形态标签与 AI 视觉标签,并聚合生成全局顶层 tags 索引。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import concurrent.futures
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
import urllib.parse
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from PIL import ExifTags, Image
|
|
32
|
+
from tqdm import tqdm
|
|
33
|
+
|
|
34
|
+
# 支持的文件后缀定义
|
|
35
|
+
IMAGE_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
|
|
36
|
+
VIDEO_EXTENSIONS: set[str] = {".mp4", ".webm"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def natural_sort_key(s: str) -> list[Any]:
|
|
40
|
+
"""自然排序键函数 (使 2.jpg 排在 10.jpg 前面,对中文与英文路径均生效)。"""
|
|
41
|
+
return [
|
|
42
|
+
int(text) if text.isdigit() else text.lower() for text in re.split(r"(\d+)", s)
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encode_url_path(rel_path: str) -> str:
|
|
47
|
+
"""对相对路径的每一段进行 URL 编码并保留 '/',防止 #、?、% 和空格导致图片 404。"""
|
|
48
|
+
parts = rel_path.replace("\\", "/").split("/")
|
|
49
|
+
encoded_parts = [urllib.parse.quote(part) for part in parts]
|
|
50
|
+
return "/".join(encoded_parts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_image_info(path: Path) -> tuple[int, int, int | None]:
|
|
54
|
+
"""
|
|
55
|
+
使用 Pillow 读取图片信息:
|
|
56
|
+
返回: (实际显示宽度, 实际显示高度, EXIF 拍摄时间戳)
|
|
57
|
+
自动校正 EXIF Orientation 旋转 (5, 6, 7, 8 交换宽高)。
|
|
58
|
+
"""
|
|
59
|
+
width, height = 0, 0
|
|
60
|
+
exif_timestamp: int | None = None
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with Image.open(path) as img:
|
|
64
|
+
width, height = img.size
|
|
65
|
+
|
|
66
|
+
# 1. 处理 EXIF Orientation 旋转
|
|
67
|
+
exif = img.getexif()
|
|
68
|
+
if exif:
|
|
69
|
+
orientation = exif.get(ExifTags.Base.Orientation)
|
|
70
|
+
# 方向 5, 6, 7, 8 需要对调宽高
|
|
71
|
+
if orientation in (5, 6, 7, 8):
|
|
72
|
+
width, height = height, width
|
|
73
|
+
|
|
74
|
+
# 2. 尝试读取真实拍摄时间 (DateTimeOriginal / DateTime)
|
|
75
|
+
dt_str: str | None = None
|
|
76
|
+
if hasattr(img, "_getexif"):
|
|
77
|
+
raw_exif = img._getexif()
|
|
78
|
+
if raw_exif:
|
|
79
|
+
# 0x9003: DateTimeOriginal, 0x0132: DateTime
|
|
80
|
+
dt_str = raw_exif.get(0x9003) or raw_exif.get(0x0132)
|
|
81
|
+
|
|
82
|
+
if dt_str and isinstance(dt_str, str):
|
|
83
|
+
try:
|
|
84
|
+
# 常见格式: "YYYY:MM:DD HH:MM:SS"
|
|
85
|
+
dt_clean = dt_str.strip()[:19]
|
|
86
|
+
dt = datetime.strptime(dt_clean, "%Y:%m:%d %H:%M:%S")
|
|
87
|
+
exif_timestamp = int(dt.timestamp())
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
return width, height, exif_timestamp
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_video_duration(path: Path) -> float:
|
|
97
|
+
"""纯 Python 解析 MP4 文件头部/尾部 moov-mvhd box 获取视频时长 (秒),避免引入庞大外部依赖。"""
|
|
98
|
+
try:
|
|
99
|
+
with open(path, "rb") as f:
|
|
100
|
+
file_size = path.stat().st_size
|
|
101
|
+
while f.tell() < file_size:
|
|
102
|
+
header = f.read(8)
|
|
103
|
+
if len(header) < 8:
|
|
104
|
+
break
|
|
105
|
+
size = int.from_bytes(header[:4], "big")
|
|
106
|
+
atom_type = header[4:8]
|
|
107
|
+
|
|
108
|
+
# 64位超大尺寸 (size == 1) 或直到文件末尾 (size == 0)
|
|
109
|
+
if size == 1:
|
|
110
|
+
large_size_bytes = f.read(8)
|
|
111
|
+
if len(large_size_bytes) < 8:
|
|
112
|
+
break
|
|
113
|
+
payload_size = int.from_bytes(large_size_bytes, "big") - 16
|
|
114
|
+
elif size >= 8:
|
|
115
|
+
payload_size = size - 8
|
|
116
|
+
elif size == 0:
|
|
117
|
+
payload_size = file_size - f.tell()
|
|
118
|
+
else:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
if atom_type == b"moov":
|
|
122
|
+
moov_end = f.tell() + payload_size
|
|
123
|
+
while f.tell() < moov_end:
|
|
124
|
+
sub_header = f.read(8)
|
|
125
|
+
if len(sub_header) < 8:
|
|
126
|
+
break
|
|
127
|
+
sub_size = int.from_bytes(sub_header[:4], "big")
|
|
128
|
+
sub_type = sub_header[4:8]
|
|
129
|
+
|
|
130
|
+
if sub_size == 1:
|
|
131
|
+
sub_large = f.read(8)
|
|
132
|
+
if len(sub_large) < 8:
|
|
133
|
+
break
|
|
134
|
+
sub_payload = int.from_bytes(sub_large, "big") - 16
|
|
135
|
+
elif sub_size >= 8:
|
|
136
|
+
sub_payload = sub_size - 8
|
|
137
|
+
else:
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
if sub_type == b"mvhd":
|
|
141
|
+
mvhd_data = f.read(min(sub_payload, 32))
|
|
142
|
+
if len(mvhd_data) >= 20:
|
|
143
|
+
version = mvhd_data[0]
|
|
144
|
+
if version == 0:
|
|
145
|
+
timescale = int.from_bytes(mvhd_data[12:16], "big")
|
|
146
|
+
duration = int.from_bytes(mvhd_data[16:20], "big")
|
|
147
|
+
else:
|
|
148
|
+
timescale = int.from_bytes(mvhd_data[20:24], "big")
|
|
149
|
+
duration = int.from_bytes(mvhd_data[24:32], "big")
|
|
150
|
+
if timescale > 0:
|
|
151
|
+
return round(duration / timescale, 1)
|
|
152
|
+
return 0.0
|
|
153
|
+
else:
|
|
154
|
+
if sub_payload > 0:
|
|
155
|
+
f.seek(sub_payload, os.SEEK_CUR)
|
|
156
|
+
break
|
|
157
|
+
else:
|
|
158
|
+
if payload_size > 0:
|
|
159
|
+
f.seek(payload_size, os.SEEK_CUR)
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
return 0.0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def extract_directory_tags(folder_path: str) -> list[str]:
|
|
166
|
+
"""从文件夹路径中提取有意义的分类标签 (过滤掉 [60P 1GB]、NO.172、纯数字等噪点)。"""
|
|
167
|
+
tags: list[str] = []
|
|
168
|
+
if not folder_path:
|
|
169
|
+
return tags
|
|
170
|
+
|
|
171
|
+
for part in folder_path.split("/"):
|
|
172
|
+
if not part:
|
|
173
|
+
continue
|
|
174
|
+
# 清理常见图集括号与序号噪点
|
|
175
|
+
cleaned = re.sub(
|
|
176
|
+
r"\[.*?\]|\(.*?\)|NO\.\d+|^\d+_", " ", part, flags=re.IGNORECASE
|
|
177
|
+
)
|
|
178
|
+
for token in cleaned.split():
|
|
179
|
+
token = token.strip()
|
|
180
|
+
if len(token) > 1 and not token.isdigit():
|
|
181
|
+
tags.append(token)
|
|
182
|
+
return tags
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def compute_aspect_tags(
|
|
186
|
+
width: int, height: int, ext: str, media_type: str
|
|
187
|
+
) -> list[str]:
|
|
188
|
+
"""根据画面尺寸与文件格式生成形态属性标签。"""
|
|
189
|
+
tags: list[str] = []
|
|
190
|
+
if media_type == "image":
|
|
191
|
+
if width > 0 and height > 0:
|
|
192
|
+
ratio = width / height
|
|
193
|
+
if ratio >= 1.2:
|
|
194
|
+
tags.append("横屏")
|
|
195
|
+
elif ratio <= 0.83:
|
|
196
|
+
tags.append("竖屏")
|
|
197
|
+
else:
|
|
198
|
+
tags.append("方图")
|
|
199
|
+
|
|
200
|
+
if max(width, height) >= 3840 or min(width, height) >= 1440:
|
|
201
|
+
tags.append("4K")
|
|
202
|
+
|
|
203
|
+
if ext == "gif":
|
|
204
|
+
tags.append("动图")
|
|
205
|
+
elif media_type == "video":
|
|
206
|
+
tags.append("视频")
|
|
207
|
+
|
|
208
|
+
return tags
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_media_file(
|
|
212
|
+
item: tuple[Path, str, str, int, int],
|
|
213
|
+
) -> tuple[str, int, int, int, int, float]:
|
|
214
|
+
"""
|
|
215
|
+
单文件解析任务 (供多线程池并发调用)。
|
|
216
|
+
返回: (rel_path, size, mtime, width, height, final_timestamp, duration)
|
|
217
|
+
"""
|
|
218
|
+
full_path, rel_path, media_type, file_size, file_mtime = item
|
|
219
|
+
width, height = 0, 0
|
|
220
|
+
duration = 0.0
|
|
221
|
+
final_timestamp = file_mtime
|
|
222
|
+
|
|
223
|
+
if media_type == "image":
|
|
224
|
+
w, h, exif_time = get_image_info(full_path)
|
|
225
|
+
width, height = w, h
|
|
226
|
+
if exif_time:
|
|
227
|
+
final_timestamp = exif_time
|
|
228
|
+
else:
|
|
229
|
+
duration = get_video_duration(full_path)
|
|
230
|
+
|
|
231
|
+
return rel_path, file_size, file_mtime, width, height, final_timestamp, duration
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_parser(
|
|
235
|
+
parser: argparse.ArgumentParser | None = None,
|
|
236
|
+
) -> argparse.ArgumentParser:
|
|
237
|
+
"""构建 assets 命令的命令行参数解析器。"""
|
|
238
|
+
if parser is None:
|
|
239
|
+
parser = argparse.ArgumentParser(description="照片墙 assets.js 全量生成脚本")
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
"--dir",
|
|
242
|
+
"-d",
|
|
243
|
+
"--home",
|
|
244
|
+
type=Path,
|
|
245
|
+
default=None,
|
|
246
|
+
help="项目根目录路径 (默认: 当前工作目录)",
|
|
247
|
+
)
|
|
248
|
+
parser.add_argument(
|
|
249
|
+
"--title",
|
|
250
|
+
type=str,
|
|
251
|
+
default=None,
|
|
252
|
+
help="相册标题 (写入 meta.title,默认从 --home 目录名称提取)",
|
|
253
|
+
)
|
|
254
|
+
parser.add_argument(
|
|
255
|
+
"--force",
|
|
256
|
+
action="store_true",
|
|
257
|
+
help="忽略本地元数据缓存,强制重新使用多线程读取并解析所有媒体文件的尺寸与时长",
|
|
258
|
+
)
|
|
259
|
+
return parser
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def run(args: argparse.Namespace) -> None:
|
|
263
|
+
"""执行 assets.js 生成主流程。"""
|
|
264
|
+
project_dir = (
|
|
265
|
+
(getattr(args, "home", None) or getattr(args, "dir", None)) or Path.cwd()
|
|
266
|
+
).resolve()
|
|
267
|
+
album_title = getattr(args, "title", None) or project_dir.name or "PhotoWall"
|
|
268
|
+
assets_dir = project_dir / "assets"
|
|
269
|
+
tags_file = project_dir / "tags.json"
|
|
270
|
+
output_file = project_dir / "assets.js"
|
|
271
|
+
media_cache_dir = project_dir / ".cache"
|
|
272
|
+
media_cache_dir.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
media_cache_file = media_cache_dir / "assets_cache.json"
|
|
274
|
+
|
|
275
|
+
if not assets_dir.is_dir():
|
|
276
|
+
print(f"[错误] 未找到 assets 目录: {assets_dir.resolve()}")
|
|
277
|
+
sys.exit(1)
|
|
278
|
+
|
|
279
|
+
# 1. 检查并读取 tags.json
|
|
280
|
+
tags_data: dict[str, Any] | None = None
|
|
281
|
+
tags_cache: dict[str, dict[str, Any]] = {}
|
|
282
|
+
|
|
283
|
+
if tags_file.is_file():
|
|
284
|
+
try:
|
|
285
|
+
with open(tags_file, "r", encoding="utf-8") as f:
|
|
286
|
+
tags_data = json.load(f)
|
|
287
|
+
tags_cache = tags_data.get("files", {})
|
|
288
|
+
print(
|
|
289
|
+
f"[*] 已加载标签缓存文件: {tags_file.resolve()} (条目数: {len(tags_cache)})"
|
|
290
|
+
)
|
|
291
|
+
except Exception as e:
|
|
292
|
+
print(f"[警告] tags.json 解析失败: {e}")
|
|
293
|
+
else:
|
|
294
|
+
print("\n" + "=" * 70)
|
|
295
|
+
print("[警告] 未检测到 tags.json 标签缓存文件!")
|
|
296
|
+
print(f" 期望路径: {tags_file.resolve()}")
|
|
297
|
+
print(" 建议先运行: photowall tags 生成 AI 标签。")
|
|
298
|
+
print(" 当前将以无 AI 标签模式继续生成 assets.js...")
|
|
299
|
+
print("=" * 70 + "\n")
|
|
300
|
+
|
|
301
|
+
# 2. 读取媒体元数据缓存 (assets_cache.json)
|
|
302
|
+
media_cache: dict[str, dict[str, Any]] = {}
|
|
303
|
+
if not args.force and media_cache_file.is_file():
|
|
304
|
+
try:
|
|
305
|
+
with open(media_cache_file, "r", encoding="utf-8") as f:
|
|
306
|
+
media_cache = json.load(f)
|
|
307
|
+
print(
|
|
308
|
+
f"[*] 已载入媒体元数据缓存: {media_cache_file.resolve()} (历史条目: {len(media_cache)})"
|
|
309
|
+
)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
print(f"[警告] 读取媒体缓存失败: {e}")
|
|
312
|
+
|
|
313
|
+
# 3. 扫描 assets 目录下的所有媒体文件
|
|
314
|
+
print(f"[*] 扫描资源目录: {assets_dir.resolve()}")
|
|
315
|
+
raw_files: list[Path] = []
|
|
316
|
+
for p in assets_dir.rglob("*"):
|
|
317
|
+
if p.is_file():
|
|
318
|
+
ext = p.suffix.lower()
|
|
319
|
+
if ext in IMAGE_EXTENSIONS or ext in VIDEO_EXTENSIONS:
|
|
320
|
+
raw_files.append(p)
|
|
321
|
+
|
|
322
|
+
# 自然排序
|
|
323
|
+
raw_files.sort(key=lambda p: natural_sort_key(p.relative_to(assets_dir).as_posix()))
|
|
324
|
+
print(f"[*] 扫描到媒体文件总数: {len(raw_files)}")
|
|
325
|
+
|
|
326
|
+
# 4. 统计文件属性并比对缓存 (检测新增、过期及已删除)
|
|
327
|
+
file_info_list: list[tuple[Path, str, str, int, int]] = []
|
|
328
|
+
to_process: list[tuple[Path, str, str, int, int]] = []
|
|
329
|
+
current_disk_set: set[str] = set()
|
|
330
|
+
|
|
331
|
+
for p in raw_files:
|
|
332
|
+
rel_to_assets = p.relative_to(assets_dir).as_posix()
|
|
333
|
+
current_disk_set.add(rel_to_assets)
|
|
334
|
+
file_stat = p.stat()
|
|
335
|
+
file_size = file_stat.st_size
|
|
336
|
+
file_mtime = int(file_stat.st_mtime)
|
|
337
|
+
ext = p.suffix.lower()
|
|
338
|
+
media_type = "video" if ext in VIDEO_EXTENSIONS else "image"
|
|
339
|
+
|
|
340
|
+
file_tuple = (p, rel_to_assets, media_type, file_size, file_mtime)
|
|
341
|
+
file_info_list.append(file_tuple)
|
|
342
|
+
|
|
343
|
+
# 检查缓存是否有效 (文件大小和修改时间均匹配)
|
|
344
|
+
cached_entry = media_cache.get(rel_to_assets)
|
|
345
|
+
if (
|
|
346
|
+
not cached_entry
|
|
347
|
+
or cached_entry.get("size") != file_size
|
|
348
|
+
or cached_entry.get("mtime") != file_mtime
|
|
349
|
+
):
|
|
350
|
+
to_process.append(file_tuple)
|
|
351
|
+
|
|
352
|
+
cached_count = len(raw_files) - len(to_process)
|
|
353
|
+
print(f"[*] 媒体缓存比对: 命中缓存 {cached_count} 个,需解析 {len(to_process)} 个")
|
|
354
|
+
|
|
355
|
+
# 5. 多线程并发解析新增或变动的媒体文件
|
|
356
|
+
if to_process:
|
|
357
|
+
max_workers = min(16, (os.cpu_count() or 4) * 2)
|
|
358
|
+
print(f"[*] 启动多线程并发解析 (线程数: {max_workers})...")
|
|
359
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
360
|
+
results = list(
|
|
361
|
+
tqdm(
|
|
362
|
+
executor.map(parse_media_file, to_process),
|
|
363
|
+
total=len(to_process),
|
|
364
|
+
desc="多线程解析媒体",
|
|
365
|
+
unit="文件",
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
# pyrefly: ignore[bad-unpacking]
|
|
369
|
+
for (
|
|
370
|
+
rel_path,
|
|
371
|
+
file_size,
|
|
372
|
+
file_mtime,
|
|
373
|
+
width,
|
|
374
|
+
height,
|
|
375
|
+
final_timestamp,
|
|
376
|
+
duration,
|
|
377
|
+
) in results:
|
|
378
|
+
media_cache[rel_path] = {
|
|
379
|
+
"size": file_size,
|
|
380
|
+
"mtime": file_mtime,
|
|
381
|
+
"width": width,
|
|
382
|
+
"height": height,
|
|
383
|
+
"timestamp": final_timestamp,
|
|
384
|
+
"duration": duration,
|
|
385
|
+
}
|
|
386
|
+
else:
|
|
387
|
+
print("[*] 所有媒体文件均已命中元数据缓存,跳过耗时解码!")
|
|
388
|
+
|
|
389
|
+
# 6. 维护缓存一致性:自动移除磁盘上已删除文件的陈旧条目
|
|
390
|
+
cleaned_cache = {
|
|
391
|
+
rel: entry for rel, entry in media_cache.items() if rel in current_disk_set
|
|
392
|
+
}
|
|
393
|
+
purged_count = len(media_cache) - len(cleaned_cache)
|
|
394
|
+
if purged_count > 0:
|
|
395
|
+
print(f"[*] 缓存维护: 已从缓存中剔除 {purged_count} 个已删除文件的陈旧条目。")
|
|
396
|
+
|
|
397
|
+
# 原子写入保存最新的缓存文件
|
|
398
|
+
temp_cache_file = media_cache_dir / "assets_cache.json.tmp"
|
|
399
|
+
with open(temp_cache_file, "w", encoding="utf-8") as f:
|
|
400
|
+
json.dump(cleaned_cache, f, ensure_ascii=False)
|
|
401
|
+
temp_cache_file.replace(media_cache_file)
|
|
402
|
+
|
|
403
|
+
# 7. 组装 items 媒体列表并融合标签
|
|
404
|
+
items: list[dict[str, Any]] = []
|
|
405
|
+
all_folder_paths: set[str] = {""} # 根目录为 ""
|
|
406
|
+
mismatched_tags_count = 0
|
|
407
|
+
missing_tags_count = 0
|
|
408
|
+
total_size = 0
|
|
409
|
+
image_count = 0
|
|
410
|
+
video_count = 0
|
|
411
|
+
|
|
412
|
+
print("[*] 正在组装媒体数据并融合标签...")
|
|
413
|
+
for idx, (full_path, rel_to_assets, media_type, file_size, file_mtime) in enumerate(
|
|
414
|
+
file_info_list, start=1
|
|
415
|
+
):
|
|
416
|
+
total_size += file_size
|
|
417
|
+
ext = full_path.suffix.lower().lstrip(".")
|
|
418
|
+
if media_type == "image":
|
|
419
|
+
image_count += 1
|
|
420
|
+
else:
|
|
421
|
+
video_count += 1
|
|
422
|
+
|
|
423
|
+
# 收集目录路径
|
|
424
|
+
parent_rel = full_path.parent.relative_to(assets_dir).as_posix()
|
|
425
|
+
folder_path = "" if parent_rel == "." else parent_rel
|
|
426
|
+
if folder_path:
|
|
427
|
+
curr = ""
|
|
428
|
+
for part in folder_path.split("/"):
|
|
429
|
+
curr = f"{curr}/{part}" if curr else part
|
|
430
|
+
all_folder_paths.add(curr)
|
|
431
|
+
|
|
432
|
+
# 直接从已维护的缓存中瞬时获取宽高、时长与拍摄时间
|
|
433
|
+
cached_meta = cleaned_cache.get(rel_to_assets, {})
|
|
434
|
+
width = cached_meta.get("width", 0)
|
|
435
|
+
height = cached_meta.get("height", 0)
|
|
436
|
+
final_timestamp = cached_meta.get("timestamp", file_mtime)
|
|
437
|
+
duration = cached_meta.get("duration", 0.0)
|
|
438
|
+
|
|
439
|
+
# 匹配 tags.json
|
|
440
|
+
ai_tags: list[str] = []
|
|
441
|
+
if tags_data is not None and media_type == "image":
|
|
442
|
+
cached_entry = tags_cache.get(rel_to_assets)
|
|
443
|
+
if cached_entry:
|
|
444
|
+
c_size = cached_entry.get("size")
|
|
445
|
+
c_mtime = cached_entry.get("mtime")
|
|
446
|
+
if c_size != file_size or c_mtime != file_mtime:
|
|
447
|
+
mismatched_tags_count += 1
|
|
448
|
+
tqdm.write(
|
|
449
|
+
f"[警告] 图片信息不匹配: {full_path.resolve()}\n"
|
|
450
|
+
f" 磁盘: (size={file_size}, mtime={file_mtime}) vs 缓存: (size={c_size}, mtime={c_mtime})\n"
|
|
451
|
+
f" 可能 tags.json 不是最新的,请考虑重新运行 photowall tags"
|
|
452
|
+
)
|
|
453
|
+
ai_tags = cached_entry.get("tags", [])
|
|
454
|
+
else:
|
|
455
|
+
missing_tags_count += 1
|
|
456
|
+
tqdm.write(
|
|
457
|
+
f"[警告] 图片未在 tags.json 中找到: {full_path.resolve()}\n"
|
|
458
|
+
f" 该图片可能为新增文件,请考虑重新运行 photowall tags 进行打标"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# 标签融合:目录标签 + 形态属性标签 + AI标签
|
|
462
|
+
dir_tags = extract_directory_tags(folder_path)
|
|
463
|
+
aspect_tags = compute_aspect_tags(width, height, ext, media_type)
|
|
464
|
+
|
|
465
|
+
merged_tags_list: list[str] = []
|
|
466
|
+
seen_tags: set[str] = set()
|
|
467
|
+
for t in dir_tags + ai_tags + aspect_tags:
|
|
468
|
+
if t and t not in seen_tags:
|
|
469
|
+
seen_tags.add(t)
|
|
470
|
+
merged_tags_list.append(t)
|
|
471
|
+
|
|
472
|
+
# URL 编码的访问安全路径
|
|
473
|
+
safe_src = f"assets/{encode_url_path(rel_to_assets)}"
|
|
474
|
+
|
|
475
|
+
item_obj = {
|
|
476
|
+
"id": idx,
|
|
477
|
+
"src": safe_src,
|
|
478
|
+
"name": full_path.name,
|
|
479
|
+
"folder": folder_path,
|
|
480
|
+
"type": media_type,
|
|
481
|
+
"ext": ext,
|
|
482
|
+
"size": file_size,
|
|
483
|
+
"mtime": final_timestamp,
|
|
484
|
+
"width": width,
|
|
485
|
+
"height": height,
|
|
486
|
+
"duration": duration,
|
|
487
|
+
"tags": merged_tags_list,
|
|
488
|
+
}
|
|
489
|
+
items.append(item_obj)
|
|
490
|
+
|
|
491
|
+
if mismatched_tags_count > 0 or missing_tags_count > 0:
|
|
492
|
+
print("\n" + "-" * 60)
|
|
493
|
+
if missing_tags_count > 0:
|
|
494
|
+
print(f"[提示] 共有 {missing_tags_count} 个图片文件未在 tags.json 中找到。")
|
|
495
|
+
if mismatched_tags_count > 0:
|
|
496
|
+
print(
|
|
497
|
+
f"[提示] 共有 {mismatched_tags_count} 个图片文件属性与 tags.json 不匹配。"
|
|
498
|
+
)
|
|
499
|
+
print(" 建议运行: photowall tags 更新标签缓存。")
|
|
500
|
+
print("-" * 60 + "\n")
|
|
501
|
+
|
|
502
|
+
# 5. 构建 folders 列表 (计算 directCount, totalCount, subFolderCount, cover)
|
|
503
|
+
# 排序目录:根目录 "" 排第一,其余自然排序
|
|
504
|
+
sorted_folder_paths = sorted(
|
|
505
|
+
all_folder_paths,
|
|
506
|
+
key=lambda p: (0 if p == "" else 1, natural_sort_key(p)),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
folders: list[dict[str, Any]] = []
|
|
510
|
+
for f_path in sorted_folder_paths:
|
|
511
|
+
if f_path == "":
|
|
512
|
+
f_name = "根目录"
|
|
513
|
+
parent: str | None = None
|
|
514
|
+
depth = 0
|
|
515
|
+
else:
|
|
516
|
+
parts = f_path.split("/")
|
|
517
|
+
f_name = parts[-1]
|
|
518
|
+
parent = "/".join(parts[:-1]) if len(parts) > 1 else ""
|
|
519
|
+
depth = len(parts)
|
|
520
|
+
|
|
521
|
+
# 直属子目录数量
|
|
522
|
+
sub_folders = [
|
|
523
|
+
p
|
|
524
|
+
for p in all_folder_paths
|
|
525
|
+
if p != f_path
|
|
526
|
+
and (
|
|
527
|
+
(f_path == "" and "/" not in p)
|
|
528
|
+
or (p.startswith(f"{f_path}/") and len(p.split("/")) == depth + 1)
|
|
529
|
+
)
|
|
530
|
+
]
|
|
531
|
+
sub_folder_count = len(sub_folders)
|
|
532
|
+
|
|
533
|
+
# 统计媒体数量
|
|
534
|
+
direct_items = [it for it in items if it["folder"] == f_path]
|
|
535
|
+
direct_count = len(direct_items)
|
|
536
|
+
|
|
537
|
+
if f_path == "":
|
|
538
|
+
total_items = items
|
|
539
|
+
else:
|
|
540
|
+
total_items = [
|
|
541
|
+
it
|
|
542
|
+
for it in items
|
|
543
|
+
if it["folder"] == f_path or it["folder"].startswith(f"{f_path}/")
|
|
544
|
+
]
|
|
545
|
+
folder_total_count = len(total_items)
|
|
546
|
+
|
|
547
|
+
# 封面图:优先直属第一张图片,次选子目录第一张图片,无图则为 null
|
|
548
|
+
cover: str | None = None
|
|
549
|
+
for it in direct_items:
|
|
550
|
+
if it["type"] == "image":
|
|
551
|
+
cover = it["src"]
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
if cover is None:
|
|
555
|
+
for it in total_items:
|
|
556
|
+
if it["type"] == "image":
|
|
557
|
+
cover = it["src"]
|
|
558
|
+
break
|
|
559
|
+
|
|
560
|
+
folder_obj = {
|
|
561
|
+
"path": f_path,
|
|
562
|
+
"name": f_name,
|
|
563
|
+
"parent": parent,
|
|
564
|
+
"depth": depth,
|
|
565
|
+
"subFolderCount": sub_folder_count,
|
|
566
|
+
"directCount": direct_count,
|
|
567
|
+
"totalCount": folder_total_count,
|
|
568
|
+
"cover": cover,
|
|
569
|
+
}
|
|
570
|
+
folders.append(folder_obj)
|
|
571
|
+
|
|
572
|
+
# 6. 聚合全局 tags 索引 (按出现次数降序排列)
|
|
573
|
+
tag_counts: dict[str, int] = {}
|
|
574
|
+
for it in items:
|
|
575
|
+
for t in it["tags"]:
|
|
576
|
+
tag_counts[t] = tag_counts.get(t, 0) + 1
|
|
577
|
+
|
|
578
|
+
sorted_tags = [
|
|
579
|
+
{"name": t, "count": count}
|
|
580
|
+
for t, count in sorted(
|
|
581
|
+
tag_counts.items(), key=lambda x: (-x[1], natural_sort_key(x[0]))
|
|
582
|
+
)
|
|
583
|
+
]
|
|
584
|
+
|
|
585
|
+
# 7. 组装最终 PHOTO_WALL_DATA 结构
|
|
586
|
+
now_iso = datetime.now(UTC).astimezone().isoformat()
|
|
587
|
+
wall_data = {
|
|
588
|
+
"meta": {
|
|
589
|
+
"title": album_title,
|
|
590
|
+
"generatedAt": now_iso,
|
|
591
|
+
"basePath": "assets",
|
|
592
|
+
"totalCount": len(items),
|
|
593
|
+
"imageCount": image_count,
|
|
594
|
+
"videoCount": video_count,
|
|
595
|
+
"folderCount": len(folders),
|
|
596
|
+
"tagCount": len(sorted_tags),
|
|
597
|
+
"totalSize": total_size,
|
|
598
|
+
},
|
|
599
|
+
"folders": folders,
|
|
600
|
+
"tags": sorted_tags,
|
|
601
|
+
"items": items,
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
# 8. 无条件生成写入 assets.js (原子写入)
|
|
605
|
+
temp_output_file = project_dir / "assets.js.tmp"
|
|
606
|
+
js_content = (
|
|
607
|
+
"// 由 photowall assets 自动生成\nwindow.PHOTO_WALL_DATA = "
|
|
608
|
+
+ json.dumps(wall_data, ensure_ascii=False, indent=2)
|
|
609
|
+
+ ";\n"
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
with open(temp_output_file, "w", encoding="utf-8") as f:
|
|
613
|
+
f.write(js_content)
|
|
614
|
+
temp_output_file.replace(output_file)
|
|
615
|
+
|
|
616
|
+
print("\n" + "=" * 60)
|
|
617
|
+
print("[√] assets.js 生成成功!")
|
|
618
|
+
print(f" - 文件路径: {output_file.resolve()}")
|
|
619
|
+
print(f" - 相册标题: {album_title}")
|
|
620
|
+
print(f" - 媒体总数: {len(items)} (图片: {image_count}, 视频: {video_count})")
|
|
621
|
+
print(f" - 目录数量: {len(folders)}")
|
|
622
|
+
print(f" - 唯一标签: {len(sorted_tags)}")
|
|
623
|
+
print("=" * 60)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
627
|
+
parser = build_parser()
|
|
628
|
+
args = parser.parse_args(argv)
|
|
629
|
+
run(args)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
if __name__ == "__main__":
|
|
633
|
+
main()
|