xigua 0.1.3__tar.gz
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.
- xigua-0.1.3/PKG-INFO +67 -0
- xigua-0.1.3/README.md +45 -0
- xigua-0.1.3/license.txt +1 -0
- xigua-0.1.3/pyproject.toml +44 -0
- xigua-0.1.3/setup.cfg +4 -0
- xigua-0.1.3/xigua/__init__.py +5 -0
- xigua-0.1.3/xigua/__version__.py +1 -0
- xigua-0.1.3/xigua/_util.py +12 -0
- xigua-0.1.3/xigua/geo/__init__.py +3 -0
- xigua-0.1.3/xigua/geo/reverse.py +118 -0
- xigua-0.1.3/xigua/media/__init__.py +3 -0
- xigua-0.1.3/xigua/media/cli.py +43 -0
- xigua-0.1.3/xigua/media/constants.py +37 -0
- xigua-0.1.3/xigua/media/exif.py +184 -0
- xigua-0.1.3/xigua/media/image.py +37 -0
- xigua-0.1.3/xigua/media/metadata.py +232 -0
- xigua-0.1.3/xigua/media/video.py +226 -0
- xigua-0.1.3/xigua.egg-info/PKG-INFO +67 -0
- xigua-0.1.3/xigua.egg-info/SOURCES.txt +21 -0
- xigua-0.1.3/xigua.egg-info/dependency_links.txt +1 -0
- xigua-0.1.3/xigua.egg-info/entry_points.txt +2 -0
- xigua-0.1.3/xigua.egg-info/requires.txt +5 -0
- xigua-0.1.3/xigua.egg-info/top_level.txt +1 -0
xigua-0.1.3/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xigua
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: 西瓜小工具集 — 各类实用小功能
|
|
5
|
+
Author-email: xigua <2587125111@qq.com>
|
|
6
|
+
License: Copyright © 2024 xigua Authors. All Rights Reserve.
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/xigua
|
|
8
|
+
Keywords: utilities,tools,metadata,exif,ffprobe,media
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: Pillow>=9.0.0
|
|
19
|
+
Requires-Dist: PyYAML<7.0,>=6.0
|
|
20
|
+
Provides-Extra: heif
|
|
21
|
+
Requires-Dist: pillow-heif>=0.10.0; extra == "heif"
|
|
22
|
+
|
|
23
|
+
# xigua — 小工具集
|
|
24
|
+
|
|
25
|
+
各类实用小功能,按模块组织,持续扩展。
|
|
26
|
+
|
|
27
|
+
## 已包含模块
|
|
28
|
+
|
|
29
|
+
### media — 本地图片/视频元数据
|
|
30
|
+
|
|
31
|
+
读取本地图片或视频的全部元数据(中文键名输出)。
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from xigua.media import MediaMetadataService
|
|
35
|
+
|
|
36
|
+
# 读取为字典
|
|
37
|
+
data = MediaMetadataService.read("/path/to/photo.jpg")
|
|
38
|
+
|
|
39
|
+
# 直接格式化输出
|
|
40
|
+
print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="text"))
|
|
41
|
+
print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="yaml", include_md5=False))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
命令行:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
xigua-media /path/to/photo.jpg
|
|
48
|
+
xigua-media /path/to/clip.mp4 --fmt text --no-md5
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**依赖说明:**
|
|
52
|
+
- 图片:Pillow(HEIC/HEIF 需 `pip install xigua[heif]`)
|
|
53
|
+
- 视频:系统需安装 `ffprobe`
|
|
54
|
+
|
|
55
|
+
## 安装
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install xigua
|
|
59
|
+
pip install xigua[heif] # 支持 HEIC/HEIF
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 发布
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd /Users/xigua/pypi-publish
|
|
66
|
+
./publish xigua --bump patch
|
|
67
|
+
```
|
xigua-0.1.3/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# xigua — 小工具集
|
|
2
|
+
|
|
3
|
+
各类实用小功能,按模块组织,持续扩展。
|
|
4
|
+
|
|
5
|
+
## 已包含模块
|
|
6
|
+
|
|
7
|
+
### media — 本地图片/视频元数据
|
|
8
|
+
|
|
9
|
+
读取本地图片或视频的全部元数据(中文键名输出)。
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from xigua.media import MediaMetadataService
|
|
13
|
+
|
|
14
|
+
# 读取为字典
|
|
15
|
+
data = MediaMetadataService.read("/path/to/photo.jpg")
|
|
16
|
+
|
|
17
|
+
# 直接格式化输出
|
|
18
|
+
print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="text"))
|
|
19
|
+
print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="yaml", include_md5=False))
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
命令行:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
xigua-media /path/to/photo.jpg
|
|
26
|
+
xigua-media /path/to/clip.mp4 --fmt text --no-md5
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**依赖说明:**
|
|
30
|
+
- 图片:Pillow(HEIC/HEIF 需 `pip install xigua[heif]`)
|
|
31
|
+
- 视频:系统需安装 `ffprobe`
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install xigua
|
|
37
|
+
pip install xigua[heif] # 支持 HEIC/HEIF
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 发布
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd /Users/xigua/pypi-publish
|
|
44
|
+
./publish xigua --bump patch
|
|
45
|
+
```
|
xigua-0.1.3/license.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Copyright © 2024 xigua Authors. All Rights Reserve.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xigua"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "西瓜小工具集 — 各类实用小功能"
|
|
9
|
+
authors = [
|
|
10
|
+
{name = "xigua", email = "2587125111@qq.com"}
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = {file = "license.txt"}
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
keywords = ["utilities", "tools", "metadata", "exif", "ffprobe", "media"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Multimedia",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"Pillow>=9.0.0",
|
|
27
|
+
"PyYAML>=6.0,<7.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
heif = ["pillow-heif>=0.10.0"]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
xigua-media = "xigua.media.cli:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
"Homepage" = "https://pypi.org/project/xigua"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["."]
|
|
41
|
+
include = ["xigua", "xigua.*"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.dynamic]
|
|
44
|
+
version = {attr = "xigua.__version__.VERSION"}
|
xigua-0.1.3/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = '0.1.3'
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""经纬度逆地理编码(内存缓存 + 在线查询)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
_GEO_CACHE: dict[str, str] = {}
|
|
12
|
+
_GEO_LOCK = threading.Lock()
|
|
13
|
+
|
|
14
|
+
_NOM_LAST_CALL = 0.0
|
|
15
|
+
_NOM_INTERVAL = 1.1
|
|
16
|
+
_NOM_LOCK = threading.Lock()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cache_key(lat: float, lng: float) -> str:
|
|
20
|
+
return f"{lat:.2f}:{lng:.2f}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fmt_cn(province: str, city: str) -> str:
|
|
24
|
+
province = (
|
|
25
|
+
province.replace("省", "")
|
|
26
|
+
.replace("自治区", "")
|
|
27
|
+
.replace("特别行政区", "")
|
|
28
|
+
.replace("维吾尔", "")
|
|
29
|
+
.replace("壮族", "")
|
|
30
|
+
.replace("回族", "")
|
|
31
|
+
.strip()
|
|
32
|
+
)
|
|
33
|
+
city = city.replace("市", "").strip()
|
|
34
|
+
if province and city and province != city:
|
|
35
|
+
return f"{province}·{city}"
|
|
36
|
+
return province or city
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fmt_abroad(country: str, city: str) -> str:
|
|
40
|
+
city = city.replace("市", "").strip()
|
|
41
|
+
if country and city and country != city:
|
|
42
|
+
return f"{country}·{city}"
|
|
43
|
+
return country or city
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _nominatim_lookup(lat: float, lng: float) -> str:
|
|
47
|
+
with _NOM_LOCK:
|
|
48
|
+
global _NOM_LAST_CALL
|
|
49
|
+
now = time.monotonic()
|
|
50
|
+
wait = _NOM_INTERVAL - (now - _NOM_LAST_CALL)
|
|
51
|
+
if wait > 0:
|
|
52
|
+
time.sleep(wait)
|
|
53
|
+
_NOM_LAST_CALL = time.monotonic()
|
|
54
|
+
|
|
55
|
+
url = (
|
|
56
|
+
f"https://nominatim.openstreetmap.org/reverse"
|
|
57
|
+
f"?lat={lat:.7f}&lon={lng:.7f}&format=json&accept-language=zh-CN&zoom=10"
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
req = urllib.request.Request(
|
|
61
|
+
url,
|
|
62
|
+
headers={"User-Agent": "xigua-tools/1.0 (media-metadata)"},
|
|
63
|
+
)
|
|
64
|
+
with urllib.request.urlopen(req, timeout=12) as resp:
|
|
65
|
+
r = json.loads(resp.read())
|
|
66
|
+
addr = r.get("address") or {}
|
|
67
|
+
country_code = addr.get("country_code", "").upper()
|
|
68
|
+
province = (addr.get("state") or addr.get("province") or "").strip()
|
|
69
|
+
city = (
|
|
70
|
+
addr.get("city") or addr.get("county") or addr.get("town")
|
|
71
|
+
or addr.get("village") or ""
|
|
72
|
+
).strip()
|
|
73
|
+
country = (addr.get("country") or "").strip()
|
|
74
|
+
if country_code == "CN":
|
|
75
|
+
return _fmt_cn(province, city) or country
|
|
76
|
+
return _fmt_abroad(country, city)
|
|
77
|
+
except Exception:
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _bigdata_lookup(lat: float, lng: float) -> str:
|
|
82
|
+
url = (
|
|
83
|
+
f"https://api.bigdatacloud.net/data/reverse-geocode-client"
|
|
84
|
+
f"?latitude={lat:.7f}&longitude={lng:.7f}&localityLanguage=zh"
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
88
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
89
|
+
r = json.loads(resp.read())
|
|
90
|
+
country_code = (r.get("countryCode") or "").upper()
|
|
91
|
+
province = (r.get("principalSubdivision") or "").strip()
|
|
92
|
+
city = (r.get("city") or r.get("locality") or "").strip()
|
|
93
|
+
country = (r.get("countryName") or "").strip()
|
|
94
|
+
if country_code == "CN":
|
|
95
|
+
return _fmt_cn(province, city) or country
|
|
96
|
+
return _fmt_abroad(country, city)
|
|
97
|
+
except Exception:
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def lookup_geo(lat: float | None, lng: float | None) -> str | None:
|
|
102
|
+
"""将经纬度转换为「省·市」格式的地区标签。"""
|
|
103
|
+
if lat is None or lng is None:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
lat, lng = float(lat), float(lng)
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
ck = _cache_key(lat, lng)
|
|
111
|
+
with _GEO_LOCK:
|
|
112
|
+
if ck in _GEO_CACHE:
|
|
113
|
+
return _GEO_CACHE[ck] or None
|
|
114
|
+
|
|
115
|
+
label = _nominatim_lookup(lat, lng) or _bigdata_lookup(lat, lng)
|
|
116
|
+
with _GEO_LOCK:
|
|
117
|
+
_GEO_CACHE[ck] = label
|
|
118
|
+
return label or None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""命令行:读取本地图片/视频元数据。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from xigua.media.metadata import MediaMetadataService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
parser = argparse.ArgumentParser(description="读取本地图片/视频全部元数据")
|
|
13
|
+
parser.add_argument("path", help="本地文件路径")
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--fmt", default="json", choices=sorted(MediaMetadataService.FORMATS),
|
|
16
|
+
help="输出格式(默认 json)",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("--no-md5", action="store_true", help="不计算 MD5")
|
|
19
|
+
parser.add_argument("--no-geo", action="store_true", help="不进行 GPS 逆地理编码")
|
|
20
|
+
parser.add_argument("--full-raw", action="store_true", help="text 格式下展开原始 EXIF/ffprobe")
|
|
21
|
+
args = parser.parse_args(argv)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
text = MediaMetadataService.format_file(
|
|
25
|
+
args.path,
|
|
26
|
+
fmt=args.fmt,
|
|
27
|
+
include_md5=not args.no_md5,
|
|
28
|
+
include_geo=not args.no_geo,
|
|
29
|
+
text_full_raw=args.full_raw,
|
|
30
|
+
)
|
|
31
|
+
except FileNotFoundError:
|
|
32
|
+
print(f"文件不存在: {args.path}", file=sys.stderr)
|
|
33
|
+
return 1
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f"错误: {e}", file=sys.stderr)
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
print(text)
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""媒体相关常量。"""
|
|
2
|
+
|
|
3
|
+
PHOTO_EXTS = frozenset({
|
|
4
|
+
".jpg", ".jpeg", ".png", ".webp",
|
|
5
|
+
".heic", ".heif", ".tiff", ".tif",
|
|
6
|
+
})
|
|
7
|
+
VIDEO_EXTS = frozenset({
|
|
8
|
+
".mp4", ".mov", ".avi", ".mkv",
|
|
9
|
+
".m4v", ".3gp", ".wmv",
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
MEDIA_KIND_ZH = {"photo": "图片", "video": "视频", "unknown": "未知"}
|
|
13
|
+
|
|
14
|
+
PHOTO_SUMMARY_ZH = {
|
|
15
|
+
"width": "宽度", "height": "高度", "taken_at": "拍摄时间", "camera": "相机型号",
|
|
16
|
+
"lens": "镜头", "focal_length": "焦距", "aperture": "光圈", "shutter": "快门",
|
|
17
|
+
"iso": "ISO", "exposure_bias": "曝光补偿",
|
|
18
|
+
"gps_lat": "GPS纬度", "gps_lng": "GPS经度", "gps_alt": "GPS海拔", "geo_region": "地区",
|
|
19
|
+
}
|
|
20
|
+
EXIF_PARSED_ZH = {
|
|
21
|
+
"datetime": "拍摄时间原始", "make": "制造商", "model": "型号",
|
|
22
|
+
"width": "宽度", "height": "高度", "lens": "镜头", "focal_length": "焦距",
|
|
23
|
+
"aperture": "光圈", "shutter": "快门", "iso": "ISO", "exposure_bias": "曝光补偿",
|
|
24
|
+
"gps_lat": "GPS纬度", "gps_lng": "GPS经度", "gps_alt": "GPS海拔",
|
|
25
|
+
}
|
|
26
|
+
VIDEO_SUMMARY_ZH = {
|
|
27
|
+
"width": "宽度", "height": "高度", "codec": "编码",
|
|
28
|
+
"duration": "时长秒", "duration_sec": "时长", "fps": "帧率", "bitrate": "码率kbps",
|
|
29
|
+
"file_size": "文件大小", "format": "格式", "format_long": "容器格式",
|
|
30
|
+
"datetime": "时间标签", "taken_at": "拍摄时间",
|
|
31
|
+
"make": "制造商", "model": "型号", "camera": "相机型号",
|
|
32
|
+
"gps_lat": "GPS纬度", "gps_lng": "GPS经度", "gps_alt": "GPS海拔", "geo_region": "地区",
|
|
33
|
+
}
|
|
34
|
+
IMAGE_INFO_ZH = {
|
|
35
|
+
"readable": "可读", "format": "格式", "mode": "色彩模式",
|
|
36
|
+
"width": "宽度", "height": "高度", "info": "附加信息", "error": "错误",
|
|
37
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""EXIF / GPS / 拍摄时间解析。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExifService:
|
|
11
|
+
@staticmethod
|
|
12
|
+
def parse_gps_coord(coord, ref: str) -> Optional[float]:
|
|
13
|
+
if not coord:
|
|
14
|
+
return None
|
|
15
|
+
try:
|
|
16
|
+
d, m, s = float(coord[0]), float(coord[1]), float(coord[2])
|
|
17
|
+
val = d + m / 60.0 + s / 3600.0
|
|
18
|
+
if ref in ("S", "W"):
|
|
19
|
+
val = -val
|
|
20
|
+
return round(val, 7)
|
|
21
|
+
except Exception:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def apply_exif_tag(meta: dict, tag: str, value) -> None:
|
|
26
|
+
try:
|
|
27
|
+
if tag == "DateTime" and not meta.get("datetime"):
|
|
28
|
+
meta["datetime"] = str(value)
|
|
29
|
+
elif tag == "DateTimeOriginal":
|
|
30
|
+
meta["datetime"] = str(value)
|
|
31
|
+
elif tag == "Make":
|
|
32
|
+
meta["make"] = str(value).strip()
|
|
33
|
+
elif tag == "Model":
|
|
34
|
+
meta["model"] = str(value).strip()
|
|
35
|
+
elif tag == "LensModel":
|
|
36
|
+
meta["lens"] = str(value).strip()
|
|
37
|
+
elif tag == "FNumber":
|
|
38
|
+
meta["aperture"] = f"f/{float(value):.1f}"
|
|
39
|
+
elif tag == "ExposureTime":
|
|
40
|
+
v = float(value)
|
|
41
|
+
meta["shutter"] = f"1/{round(1/v)}" if 0 < v < 1 else f"{v}s"
|
|
42
|
+
elif tag in ("ISOSpeedRatings", "PhotographicSensitivity"):
|
|
43
|
+
meta["iso"] = int(value)
|
|
44
|
+
elif tag == "FocalLength":
|
|
45
|
+
meta["focal_length"] = f"{float(value):.0f}mm"
|
|
46
|
+
elif tag == "ExposureBiasValue":
|
|
47
|
+
v = float(value)
|
|
48
|
+
meta["exposure_bias"] = f"{v:+.1f}EV" if v != 0 else "0EV"
|
|
49
|
+
elif tag == "ExifImageWidth":
|
|
50
|
+
meta.setdefault("width", int(value))
|
|
51
|
+
elif tag == "ExifImageHeight":
|
|
52
|
+
meta.setdefault("height", int(value))
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def extract_exif(path: str) -> dict:
|
|
58
|
+
try:
|
|
59
|
+
from PIL import Image, ExifTags # type: ignore
|
|
60
|
+
except ImportError:
|
|
61
|
+
return {}
|
|
62
|
+
from xigua.media.image import ImageService
|
|
63
|
+
|
|
64
|
+
meta: dict = {}
|
|
65
|
+
try:
|
|
66
|
+
img = ImageService.open_image(path)
|
|
67
|
+
exif = img.getexif()
|
|
68
|
+
if not exif:
|
|
69
|
+
return {}
|
|
70
|
+
for tag_id, value in exif.items():
|
|
71
|
+
ExifService.apply_exif_tag(meta, ExifTags.TAGS.get(tag_id, ""), value)
|
|
72
|
+
try:
|
|
73
|
+
exif_ifd = exif.get_ifd(0x8769)
|
|
74
|
+
for tag_id, value in exif_ifd.items():
|
|
75
|
+
ExifService.apply_exif_tag(meta, ExifTags.TAGS.get(tag_id, ""), value)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
try:
|
|
79
|
+
gps = exif.get_ifd(0x8825)
|
|
80
|
+
if gps:
|
|
81
|
+
lat = ExifService.parse_gps_coord(gps.get(2), str(gps.get(1, "")))
|
|
82
|
+
lng = ExifService.parse_gps_coord(gps.get(4), str(gps.get(3, "")))
|
|
83
|
+
if lat is not None:
|
|
84
|
+
meta["gps_lat"] = lat
|
|
85
|
+
if lng is not None:
|
|
86
|
+
meta["gps_lng"] = lng
|
|
87
|
+
alt = gps.get(6)
|
|
88
|
+
if alt is not None:
|
|
89
|
+
try:
|
|
90
|
+
alt_m = float(alt)
|
|
91
|
+
if gps.get(5) == 1:
|
|
92
|
+
alt_m = -alt_m
|
|
93
|
+
meta["gps_alt"] = round(alt_m, 1)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return meta
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def format_camera(make: Optional[str], model: Optional[str]) -> Optional[str]:
|
|
104
|
+
make = (make or "").strip()
|
|
105
|
+
model = (model or "").strip()
|
|
106
|
+
if not make and not model:
|
|
107
|
+
return None
|
|
108
|
+
if not make:
|
|
109
|
+
return model or None
|
|
110
|
+
if not model:
|
|
111
|
+
return make
|
|
112
|
+
make_lower, model_lower = make.lower(), model.lower()
|
|
113
|
+
if model_lower.startswith(make_lower):
|
|
114
|
+
return model
|
|
115
|
+
if model_lower.startswith(make.split()[0].lower()):
|
|
116
|
+
return model
|
|
117
|
+
return f"{make} {model}"
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def parse_exif_dt(dt_str: str) -> Optional[datetime]:
|
|
121
|
+
if not dt_str:
|
|
122
|
+
return None
|
|
123
|
+
s = str(dt_str).strip()
|
|
124
|
+
for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
|
125
|
+
try:
|
|
126
|
+
return datetime.strptime(s[:19], fmt)
|
|
127
|
+
except Exception:
|
|
128
|
+
continue
|
|
129
|
+
m = re.match(r"(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})", s)
|
|
130
|
+
if m:
|
|
131
|
+
try:
|
|
132
|
+
return datetime.strptime(m.group(1).replace("T", " "), "%Y-%m-%d %H:%M:%S")
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def parse_iso6709(loc: str) -> tuple[Optional[float], Optional[float], Optional[float]]:
|
|
139
|
+
if not loc:
|
|
140
|
+
return None, None, None
|
|
141
|
+
m = re.match(
|
|
142
|
+
r"([+-])(\d+(?:\.\d+)?)([+-])(\d+(?:\.\d+)?)([+-]?)(\d+(?:\.\d+)?)?",
|
|
143
|
+
loc.strip().rstrip("/"),
|
|
144
|
+
)
|
|
145
|
+
if not m:
|
|
146
|
+
return None, None, None
|
|
147
|
+
try:
|
|
148
|
+
lat = float(m.group(2)) * (1 if m.group(1) == "+" else -1)
|
|
149
|
+
lng = float(m.group(4)) * (1 if m.group(3) == "+" else -1)
|
|
150
|
+
alt = float(m.group(6)) if m.group(6) else None
|
|
151
|
+
if alt is not None and m.group(5) == "-":
|
|
152
|
+
alt = -alt
|
|
153
|
+
return round(lat, 7), round(lng, 7), round(alt, 1) if alt is not None else None
|
|
154
|
+
except Exception:
|
|
155
|
+
return None, None, None
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def apply_video_tags(meta: dict, tags: dict) -> None:
|
|
159
|
+
if not tags:
|
|
160
|
+
return
|
|
161
|
+
dt_priority = (
|
|
162
|
+
"com.apple.quicktime.creationdate",
|
|
163
|
+
"creation_time",
|
|
164
|
+
"date",
|
|
165
|
+
)
|
|
166
|
+
for key in dt_priority:
|
|
167
|
+
val = tags.get(key)
|
|
168
|
+
if val and re.search(r"\d{4}", str(val)):
|
|
169
|
+
meta["datetime"] = str(val)
|
|
170
|
+
break
|
|
171
|
+
make = tags.get("com.apple.quicktime.make") or tags.get("make")
|
|
172
|
+
model = tags.get("com.apple.quicktime.model") or tags.get("model")
|
|
173
|
+
if make:
|
|
174
|
+
meta["make"] = str(make).strip()
|
|
175
|
+
if model:
|
|
176
|
+
meta["model"] = str(model).strip()
|
|
177
|
+
iso_loc = tags.get("com.apple.quicktime.location.ISO6709") or tags.get("location")
|
|
178
|
+
lat, lng, alt = ExifService.parse_iso6709(str(iso_loc) if iso_loc else "")
|
|
179
|
+
if lat is not None:
|
|
180
|
+
meta["gps_lat"] = lat
|
|
181
|
+
if lng is not None:
|
|
182
|
+
meta["gps_lng"] = lng
|
|
183
|
+
if alt is not None:
|
|
184
|
+
meta["gps_alt"] = alt
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""图片打开(元数据读取用)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register_heif_opener() -> None:
|
|
9
|
+
try:
|
|
10
|
+
import pillow_heif # type: ignore
|
|
11
|
+
pillow_heif.register_heif_opener()
|
|
12
|
+
except ImportError:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
register_heif_opener()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ImageService:
|
|
20
|
+
@staticmethod
|
|
21
|
+
def open_image(path: str):
|
|
22
|
+
from PIL import Image, ImageOps, UnidentifiedImageError # type: ignore
|
|
23
|
+
|
|
24
|
+
ext = os.path.splitext(path)[1].lower()
|
|
25
|
+
try:
|
|
26
|
+
img = Image.open(path)
|
|
27
|
+
img.load()
|
|
28
|
+
return ImageOps.exif_transpose(img)
|
|
29
|
+
except (UnidentifiedImageError, OSError) as exc:
|
|
30
|
+
if ext in (".heic", ".heif"):
|
|
31
|
+
try:
|
|
32
|
+
import pillow_heif # type: ignore # noqa: F401
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"HEIC/HEIF 需要安装 pillow-heif:pip install xigua[heif]"
|
|
36
|
+
) from exc
|
|
37
|
+
raise
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""本地图片/视频元数据读取与格式化。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from xigua._util import fmt_size
|
|
14
|
+
from xigua.geo.reverse import lookup_geo
|
|
15
|
+
from xigua.media.constants import (
|
|
16
|
+
EXIF_PARSED_ZH,
|
|
17
|
+
IMAGE_INFO_ZH,
|
|
18
|
+
MEDIA_KIND_ZH,
|
|
19
|
+
PHOTO_SUMMARY_ZH,
|
|
20
|
+
VIDEO_SUMMARY_ZH,
|
|
21
|
+
)
|
|
22
|
+
from xigua.media.exif import ExifService
|
|
23
|
+
from xigua.media.video import VideoService
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MediaMetadataService:
|
|
27
|
+
"""读取本地图片或视频的全部元数据信息。"""
|
|
28
|
+
|
|
29
|
+
FORMATS = frozenset({"json", "yaml", "text"})
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def file_md5(path: str) -> str:
|
|
33
|
+
h = hashlib.md5()
|
|
34
|
+
with open(path, "rb") as f:
|
|
35
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
36
|
+
h.update(chunk)
|
|
37
|
+
return h.hexdigest()
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def zh_keys(data: dict, mapping: dict) -> dict:
|
|
41
|
+
out: dict = {}
|
|
42
|
+
for k, v in data.items():
|
|
43
|
+
if v is None:
|
|
44
|
+
continue
|
|
45
|
+
out[mapping.get(k, k)] = v
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def format(
|
|
50
|
+
data: dict,
|
|
51
|
+
fmt: str = "json",
|
|
52
|
+
*,
|
|
53
|
+
indent: int = 2,
|
|
54
|
+
text_full_raw: bool = False,
|
|
55
|
+
) -> str:
|
|
56
|
+
fmt = fmt.lower()
|
|
57
|
+
if fmt not in MediaMetadataService.FORMATS:
|
|
58
|
+
raise ValueError(f"不支持的格式: {fmt!r},可选: {sorted(MediaMetadataService.FORMATS)}")
|
|
59
|
+
|
|
60
|
+
if fmt == "json":
|
|
61
|
+
return json.dumps(data, ensure_ascii=False, indent=indent, default=str)
|
|
62
|
+
|
|
63
|
+
if fmt == "yaml":
|
|
64
|
+
return yaml.safe_dump(
|
|
65
|
+
data,
|
|
66
|
+
allow_unicode=True,
|
|
67
|
+
default_flow_style=False,
|
|
68
|
+
sort_keys=False,
|
|
69
|
+
indent=indent,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return MediaMetadataService._format_text(data, full_raw=text_full_raw)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _format_text(obj, indent: int = 0, *, full_raw: bool = False, key: str = "") -> str:
|
|
76
|
+
prefix = " " * indent
|
|
77
|
+
raw_keys = frozenset({"EXIF原始", "FFprobe原始", "图片信息"})
|
|
78
|
+
|
|
79
|
+
if isinstance(obj, dict):
|
|
80
|
+
lines: list[str] = []
|
|
81
|
+
for k, v in obj.items():
|
|
82
|
+
if not full_raw and key == "" and k in raw_keys and isinstance(v, dict):
|
|
83
|
+
lines.append(f"{prefix}{k}: (共 {len(v)} 项,format=json 查看完整)")
|
|
84
|
+
continue
|
|
85
|
+
if isinstance(v, dict):
|
|
86
|
+
if not v:
|
|
87
|
+
lines.append(f"{prefix}{k}: {{}}")
|
|
88
|
+
else:
|
|
89
|
+
lines.append(f"{prefix}{k}:")
|
|
90
|
+
lines.append(MediaMetadataService._format_text(
|
|
91
|
+
v, indent + 1, full_raw=full_raw, key=k,
|
|
92
|
+
))
|
|
93
|
+
elif isinstance(v, list):
|
|
94
|
+
if not v:
|
|
95
|
+
lines.append(f"{prefix}{k}: []")
|
|
96
|
+
elif all(not isinstance(x, (dict, list)) for x in v):
|
|
97
|
+
lines.append(f"{prefix}{k}: {', '.join(str(x) for x in v)}")
|
|
98
|
+
else:
|
|
99
|
+
lines.append(f"{prefix}{k}:")
|
|
100
|
+
for i, item in enumerate(v):
|
|
101
|
+
lines.append(f"{prefix} [{i}]")
|
|
102
|
+
lines.append(MediaMetadataService._format_text(
|
|
103
|
+
item, indent + 2, full_raw=full_raw,
|
|
104
|
+
))
|
|
105
|
+
else:
|
|
106
|
+
lines.append(f"{prefix}{k}: {v}")
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
if isinstance(obj, list):
|
|
110
|
+
lines = []
|
|
111
|
+
for i, item in enumerate(obj):
|
|
112
|
+
lines.append(f"{prefix}[{i}]")
|
|
113
|
+
lines.append(MediaMetadataService._format_text(
|
|
114
|
+
item, indent + 1, full_raw=full_raw,
|
|
115
|
+
))
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
|
118
|
+
return f"{prefix}{obj}"
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def format_file(
|
|
122
|
+
local_path: str,
|
|
123
|
+
fmt: str = "json",
|
|
124
|
+
*,
|
|
125
|
+
include_md5: bool = True,
|
|
126
|
+
include_geo: bool = True,
|
|
127
|
+
indent: int = 2,
|
|
128
|
+
text_full_raw: bool = False,
|
|
129
|
+
) -> str:
|
|
130
|
+
data = MediaMetadataService.read(
|
|
131
|
+
local_path, include_md5=include_md5, include_geo=include_geo,
|
|
132
|
+
)
|
|
133
|
+
return MediaMetadataService.format(
|
|
134
|
+
data, fmt, indent=indent, text_full_raw=text_full_raw,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def read(
|
|
139
|
+
local_path: str,
|
|
140
|
+
*,
|
|
141
|
+
include_md5: bool = True,
|
|
142
|
+
include_geo: bool = True,
|
|
143
|
+
) -> dict:
|
|
144
|
+
"""读取本地图片或视频的全部元数据信息。
|
|
145
|
+
|
|
146
|
+
图片:Pillow 基础信息 + 完整 EXIF + 解析后的常用字段。
|
|
147
|
+
视频:ffprobe 完整 JSON + 解析后的常用字段。
|
|
148
|
+
"""
|
|
149
|
+
local_path = os.path.abspath(os.path.expanduser(local_path))
|
|
150
|
+
if not os.path.isfile(local_path):
|
|
151
|
+
raise FileNotFoundError(local_path)
|
|
152
|
+
|
|
153
|
+
errors: list[str] = []
|
|
154
|
+
ext = os.path.splitext(local_path)[1].lower()
|
|
155
|
+
filename = os.path.basename(local_path)
|
|
156
|
+
mime, _ = mimetypes.guess_type(local_path)
|
|
157
|
+
kind = VideoService.detect_media_kind(local_path)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
st = os.stat(local_path)
|
|
161
|
+
file_size = st.st_size
|
|
162
|
+
modified_at = datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds")
|
|
163
|
+
except OSError as e:
|
|
164
|
+
file_size = None
|
|
165
|
+
modified_at = None
|
|
166
|
+
errors.append(f"stat 失败: {e}")
|
|
167
|
+
|
|
168
|
+
file_block: dict = {
|
|
169
|
+
"路径": local_path,
|
|
170
|
+
"文件名": filename,
|
|
171
|
+
"扩展名": ext,
|
|
172
|
+
"文件大小": file_size,
|
|
173
|
+
"文件大小可读": fmt_size(file_size) if file_size is not None else None,
|
|
174
|
+
"MIME类型": mime,
|
|
175
|
+
"修改时间": modified_at,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
result: dict = {
|
|
179
|
+
"类型": MEDIA_KIND_ZH.get(kind, kind),
|
|
180
|
+
"文件": file_block,
|
|
181
|
+
"摘要": {},
|
|
182
|
+
"错误": errors,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if include_md5:
|
|
186
|
+
try:
|
|
187
|
+
file_block["MD5"] = MediaMetadataService.file_md5(local_path)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
errors.append(f"MD5 计算失败: {e}")
|
|
190
|
+
|
|
191
|
+
if kind == "photo":
|
|
192
|
+
image_info = VideoService.dump_image_info(local_path)
|
|
193
|
+
parsed = ExifService.extract_exif(local_path)
|
|
194
|
+
summary = {
|
|
195
|
+
"width": parsed.get("width") or image_info.get("width"),
|
|
196
|
+
"height": parsed.get("height") or image_info.get("height"),
|
|
197
|
+
"taken_at": ExifService.parse_exif_dt(parsed.get("datetime", "")),
|
|
198
|
+
"camera": ExifService.format_camera(parsed.get("make"), parsed.get("model")),
|
|
199
|
+
"lens": parsed.get("lens"),
|
|
200
|
+
"focal_length": parsed.get("focal_length"),
|
|
201
|
+
"aperture": parsed.get("aperture"),
|
|
202
|
+
"shutter": parsed.get("shutter"),
|
|
203
|
+
"iso": parsed.get("iso"),
|
|
204
|
+
"exposure_bias": parsed.get("exposure_bias"),
|
|
205
|
+
"gps_lat": parsed.get("gps_lat"),
|
|
206
|
+
"gps_lng": parsed.get("gps_lng"),
|
|
207
|
+
"gps_alt": parsed.get("gps_alt"),
|
|
208
|
+
}
|
|
209
|
+
if include_geo and summary.get("gps_lat") is not None and summary.get("gps_lng") is not None:
|
|
210
|
+
summary["geo_region"] = lookup_geo(summary["gps_lat"], summary["gps_lng"])
|
|
211
|
+
result["图片信息"] = MediaMetadataService.zh_keys(image_info, IMAGE_INFO_ZH)
|
|
212
|
+
result["EXIF原始"] = VideoService.dump_exif_all(local_path)
|
|
213
|
+
result["EXIF解析"] = MediaMetadataService.zh_keys(parsed, EXIF_PARSED_ZH)
|
|
214
|
+
result["摘要"] = MediaMetadataService.zh_keys(summary, PHOTO_SUMMARY_ZH)
|
|
215
|
+
|
|
216
|
+
elif kind == "video":
|
|
217
|
+
if not VideoService.cmd_available("ffprobe"):
|
|
218
|
+
errors.append("ffprobe 不可用,无法读取视频元数据")
|
|
219
|
+
else:
|
|
220
|
+
probe = VideoService.ffprobe_all(local_path)
|
|
221
|
+
if probe:
|
|
222
|
+
result["FFprobe原始"] = probe
|
|
223
|
+
summary = VideoService.parse_ffprobe_summary(probe, local_path)
|
|
224
|
+
if not include_geo:
|
|
225
|
+
summary.pop("geo_region", None)
|
|
226
|
+
result["摘要"] = MediaMetadataService.zh_keys(summary, VIDEO_SUMMARY_ZH)
|
|
227
|
+
else:
|
|
228
|
+
errors.append("ffprobe 未返回有效数据")
|
|
229
|
+
else:
|
|
230
|
+
errors.append(f"无法识别媒体类型(扩展名 {ext or '(无)'})")
|
|
231
|
+
|
|
232
|
+
return result
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""ffprobe / 图片视频元数据探测。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from xigua.geo.reverse import lookup_geo
|
|
13
|
+
from xigua.media.constants import PHOTO_EXTS, VIDEO_EXTS
|
|
14
|
+
from xigua.media.exif import ExifService
|
|
15
|
+
from xigua.media.image import ImageService
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VideoService:
|
|
21
|
+
@staticmethod
|
|
22
|
+
def cmd_available(cmd: str) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
subprocess.run([cmd, "-version"], capture_output=True, timeout=5)
|
|
25
|
+
return True
|
|
26
|
+
except Exception:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def ffprobe_all(path: str) -> dict | None:
|
|
31
|
+
try:
|
|
32
|
+
r = subprocess.run(
|
|
33
|
+
["ffprobe", "-v", "quiet", "-print_format", "json",
|
|
34
|
+
"-show_format", "-show_streams", "-show_chapters", path],
|
|
35
|
+
capture_output=True, text=True, timeout=120,
|
|
36
|
+
)
|
|
37
|
+
if r.returncode != 0:
|
|
38
|
+
return None
|
|
39
|
+
return json.loads(r.stdout)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
log.warning("ffprobe 失败: %s", e)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def parse_ffprobe_summary(data: dict, path: str = "") -> dict:
|
|
46
|
+
meta: dict = {}
|
|
47
|
+
for stream in data.get("streams", []):
|
|
48
|
+
if stream.get("codec_type") == "video":
|
|
49
|
+
meta["width"] = int(stream.get("width") or 0) or None
|
|
50
|
+
meta["height"] = int(stream.get("height") or 0) or None
|
|
51
|
+
meta["codec"] = stream.get("codec_name")
|
|
52
|
+
if "duration" in stream:
|
|
53
|
+
meta["duration"] = int(float(stream["duration"]))
|
|
54
|
+
for fps_key in ("r_frame_rate", "avg_frame_rate"):
|
|
55
|
+
fps_str = stream.get(fps_key, "")
|
|
56
|
+
if fps_str and "/" in fps_str:
|
|
57
|
+
try:
|
|
58
|
+
num, den = fps_str.split("/")
|
|
59
|
+
fps = float(num) / float(den)
|
|
60
|
+
if 1 < fps < 300:
|
|
61
|
+
meta["fps"] = round(fps, 3)
|
|
62
|
+
break
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
br = stream.get("bit_rate")
|
|
66
|
+
if br:
|
|
67
|
+
try:
|
|
68
|
+
meta["bitrate"] = int(int(br) / 1000)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
fmt = data.get("format", {})
|
|
74
|
+
if "duration" in fmt and "duration" not in meta:
|
|
75
|
+
meta["duration_sec"] = round(float(fmt["duration"]), 3)
|
|
76
|
+
meta["duration"] = int(float(fmt["duration"]))
|
|
77
|
+
elif "duration" in meta:
|
|
78
|
+
meta["duration_sec"] = float(meta["duration"])
|
|
79
|
+
size = int(fmt.get("size", 0) or 0) or None
|
|
80
|
+
meta["file_size"] = size
|
|
81
|
+
if path and size is None:
|
|
82
|
+
try:
|
|
83
|
+
meta["file_size"] = os.path.getsize(path)
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
if "bitrate" not in meta and fmt.get("bit_rate"):
|
|
87
|
+
try:
|
|
88
|
+
meta["bitrate"] = int(int(fmt["bit_rate"]) / 1000)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
fname = fmt.get("format_name", "")
|
|
92
|
+
meta["format"] = fname.split(",")[0] if fname else None
|
|
93
|
+
meta["format_long"] = fname or None
|
|
94
|
+
ExifService.apply_video_tags(meta, fmt.get("tags") or {})
|
|
95
|
+
if meta.get("datetime"):
|
|
96
|
+
meta["taken_at"] = ExifService.parse_exif_dt(meta["datetime"])
|
|
97
|
+
camera = ExifService.format_camera(meta.get("make"), meta.get("model"))
|
|
98
|
+
if camera:
|
|
99
|
+
meta["camera"] = camera
|
|
100
|
+
if meta.get("gps_lat") is not None and meta.get("gps_lng") is not None:
|
|
101
|
+
meta["geo_region"] = lookup_geo(meta["gps_lat"], meta["gps_lng"])
|
|
102
|
+
return meta
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _serialize_bytes(data: bytes, *, max_text: int = 512) -> str:
|
|
106
|
+
if not data:
|
|
107
|
+
return ""
|
|
108
|
+
try:
|
|
109
|
+
text = data.decode("utf-8")
|
|
110
|
+
except UnicodeDecodeError:
|
|
111
|
+
return f"<binary {len(data)} bytes>"
|
|
112
|
+
printable = sum(1 for c in text if c.isprintable() or c in "\t\n\r")
|
|
113
|
+
if printable < len(text) * 0.9:
|
|
114
|
+
return f"<binary {len(data)} bytes>"
|
|
115
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
116
|
+
if len(text) > max_text:
|
|
117
|
+
return text[:max_text] + "..."
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def serialize_exif_value(value) -> str | int | float | list | dict:
|
|
122
|
+
if isinstance(value, bytes):
|
|
123
|
+
return VideoService._serialize_bytes(value)
|
|
124
|
+
if hasattr(value, "numerator") and hasattr(value, "denominator"):
|
|
125
|
+
try:
|
|
126
|
+
return round(float(value), 6)
|
|
127
|
+
except Exception:
|
|
128
|
+
return str(value)
|
|
129
|
+
if isinstance(value, tuple):
|
|
130
|
+
return [VideoService.serialize_exif_value(v) for v in value]
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
_IMAGE_INFO_SKIP = frozenset({
|
|
134
|
+
"exif", "mp", "xmp", "icc_profile", "icc", "photoshop", "XML:com.adobe.xmp",
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def dump_exif_all(path: str) -> dict:
|
|
139
|
+
try:
|
|
140
|
+
from PIL import Image, ExifTags # type: ignore
|
|
141
|
+
except ImportError:
|
|
142
|
+
return {}
|
|
143
|
+
raw: dict = {}
|
|
144
|
+
try:
|
|
145
|
+
img = ImageService.open_image(path)
|
|
146
|
+
exif = img.getexif()
|
|
147
|
+
if not exif:
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
def _collect(ifd, prefix: str = "") -> None:
|
|
151
|
+
for tag_id, value in ifd.items():
|
|
152
|
+
name = ExifTags.TAGS.get(tag_id, str(tag_id))
|
|
153
|
+
key = f"{prefix}{name}" if prefix else name
|
|
154
|
+
raw[key] = VideoService.serialize_exif_value(value)
|
|
155
|
+
|
|
156
|
+
_collect(exif)
|
|
157
|
+
try:
|
|
158
|
+
_collect(exif.get_ifd(0x8769), "Exif:")
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
try:
|
|
162
|
+
gps = exif.get_ifd(0x8825)
|
|
163
|
+
if gps:
|
|
164
|
+
_collect(gps, "GPS:")
|
|
165
|
+
lat = ExifService.parse_gps_coord(gps.get(2), str(gps.get(1, "")))
|
|
166
|
+
lng = ExifService.parse_gps_coord(gps.get(4), str(gps.get(3, "")))
|
|
167
|
+
if lat is not None:
|
|
168
|
+
raw["GPS:DecimalLatitude"] = lat
|
|
169
|
+
if lng is not None:
|
|
170
|
+
raw["GPS:DecimalLongitude"] = lng
|
|
171
|
+
alt = gps.get(6)
|
|
172
|
+
if alt is not None:
|
|
173
|
+
try:
|
|
174
|
+
alt_m = float(alt)
|
|
175
|
+
if gps.get(5) == 1:
|
|
176
|
+
alt_m = -alt_m
|
|
177
|
+
raw["GPS:DecimalAltitude"] = round(alt_m, 1)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
except Exception as e:
|
|
183
|
+
log.warning("EXIF 读取失败 (%s): %s", os.path.basename(path), e)
|
|
184
|
+
return raw
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def dump_image_info(path: str) -> dict:
|
|
188
|
+
info: dict = {"readable": False}
|
|
189
|
+
try:
|
|
190
|
+
img = ImageService.open_image(path)
|
|
191
|
+
info.update({
|
|
192
|
+
"readable": True,
|
|
193
|
+
"format": img.format,
|
|
194
|
+
"mode": img.mode,
|
|
195
|
+
"width": img.width,
|
|
196
|
+
"height": img.height,
|
|
197
|
+
})
|
|
198
|
+
if hasattr(img, "info") and img.info:
|
|
199
|
+
extra: dict = {}
|
|
200
|
+
for k, v in img.info.items():
|
|
201
|
+
if k in VideoService._IMAGE_INFO_SKIP:
|
|
202
|
+
continue
|
|
203
|
+
sv = VideoService.serialize_exif_value(v)
|
|
204
|
+
if sv not in (None, "", f"<binary 0 bytes>"):
|
|
205
|
+
extra[k] = sv
|
|
206
|
+
if extra:
|
|
207
|
+
info["info"] = extra
|
|
208
|
+
except Exception as e:
|
|
209
|
+
info["error"] = str(e)
|
|
210
|
+
return info
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def detect_media_kind(path: str) -> str:
|
|
214
|
+
ext = os.path.splitext(path)[1].lower()
|
|
215
|
+
if ext in PHOTO_EXTS:
|
|
216
|
+
return "photo"
|
|
217
|
+
if ext in VIDEO_EXTS:
|
|
218
|
+
return "video"
|
|
219
|
+
try:
|
|
220
|
+
ImageService.open_image(path)
|
|
221
|
+
return "photo"
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
if VideoService.cmd_available("ffprobe") and VideoService.ffprobe_all(path):
|
|
225
|
+
return "video"
|
|
226
|
+
return "unknown"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xigua
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: 西瓜小工具集 — 各类实用小功能
|
|
5
|
+
Author-email: xigua <2587125111@qq.com>
|
|
6
|
+
License: Copyright © 2024 xigua Authors. All Rights Reserve.
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/xigua
|
|
8
|
+
Keywords: utilities,tools,metadata,exif,ffprobe,media
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: Pillow>=9.0.0
|
|
19
|
+
Requires-Dist: PyYAML<7.0,>=6.0
|
|
20
|
+
Provides-Extra: heif
|
|
21
|
+
Requires-Dist: pillow-heif>=0.10.0; extra == "heif"
|
|
22
|
+
|
|
23
|
+
# xigua — 小工具集
|
|
24
|
+
|
|
25
|
+
各类实用小功能,按模块组织,持续扩展。
|
|
26
|
+
|
|
27
|
+
## 已包含模块
|
|
28
|
+
|
|
29
|
+
### media — 本地图片/视频元数据
|
|
30
|
+
|
|
31
|
+
读取本地图片或视频的全部元数据(中文键名输出)。
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from xigua.media import MediaMetadataService
|
|
35
|
+
|
|
36
|
+
# 读取为字典
|
|
37
|
+
data = MediaMetadataService.read("/path/to/photo.jpg")
|
|
38
|
+
|
|
39
|
+
# 直接格式化输出
|
|
40
|
+
print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="text"))
|
|
41
|
+
print(MediaMetadataService.format_file("/path/to/clip.mp4", fmt="yaml", include_md5=False))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
命令行:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
xigua-media /path/to/photo.jpg
|
|
48
|
+
xigua-media /path/to/clip.mp4 --fmt text --no-md5
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**依赖说明:**
|
|
52
|
+
- 图片:Pillow(HEIC/HEIF 需 `pip install xigua[heif]`)
|
|
53
|
+
- 视频:系统需安装 `ffprobe`
|
|
54
|
+
|
|
55
|
+
## 安装
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install xigua
|
|
59
|
+
pip install xigua[heif] # 支持 HEIC/HEIF
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 发布
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd /Users/xigua/pypi-publish
|
|
66
|
+
./publish xigua --bump patch
|
|
67
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
license.txt
|
|
3
|
+
pyproject.toml
|
|
4
|
+
xigua/__init__.py
|
|
5
|
+
xigua/__version__.py
|
|
6
|
+
xigua/_util.py
|
|
7
|
+
xigua.egg-info/PKG-INFO
|
|
8
|
+
xigua.egg-info/SOURCES.txt
|
|
9
|
+
xigua.egg-info/dependency_links.txt
|
|
10
|
+
xigua.egg-info/entry_points.txt
|
|
11
|
+
xigua.egg-info/requires.txt
|
|
12
|
+
xigua.egg-info/top_level.txt
|
|
13
|
+
xigua/geo/__init__.py
|
|
14
|
+
xigua/geo/reverse.py
|
|
15
|
+
xigua/media/__init__.py
|
|
16
|
+
xigua/media/cli.py
|
|
17
|
+
xigua/media/constants.py
|
|
18
|
+
xigua/media/exif.py
|
|
19
|
+
xigua/media/image.py
|
|
20
|
+
xigua/media/metadata.py
|
|
21
|
+
xigua/media/video.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xigua
|