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 ADDED
@@ -0,0 +1,4 @@
1
+ from photowall import assets, index, tags
2
+ from photowall.cli import main
3
+
4
+ __all__ = ["assets", "index", "main", "tags"]
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()