ブログ - 写真,360度全天球写真,動画ファイルを1つのフォルダに入れてアルバムページを作る
件名にある通り,1つのフォルダに写真,360度全天球写真,動画ファイルを入れて,そのフォルダを指定して実行すると,アルバムを作成してくれるツール.Microsoft Copilotを使って作りました.
こんな感じで使います.
-fで指定されたフォルダに,サムネイルファイルや生成されたHTMLファイルが入ります.
-oで生成されたファイルをブラウザで読み込むとアルバムとして使えます.
写真の説明は,ファイル名から取得する仕様なので,ファイル名に長く記載してください.ファイルの掲載順は,ファイルのタイムスタンプです.
iPhoneの写真をmacOSにAirDropするとHEIFファイルになりますが,そのままだとうまくサムネイルが作成できないので,Finderで「イメージ変換」してJPEGにしてください.画像サイズは「実際のサイズ」で良いかと思います.
こんな感じで使います.
$ python3 generate_viewer.py -f 20260409SunriseIzumo -o ¥
20260409SunriseIzumo.html -t サンライズ出雲乗車記録2026.04.09_150分おくれ
$
-f メディアの入ったフォルダ
-o HTMLファイル名
-t ページタイトル
-oで生成されたファイルをブラウザで読み込むとアルバムとして使えます.
写真の説明は,ファイル名から取得する仕様なので,ファイル名に長く記載してください.ファイルの掲載順は,ファイルのタイムスタンプです.
iPhoneの写真をmacOSにAirDropするとHEIFファイルになりますが,そのままだとうまくサムネイルが作成できないので,Finderで「イメージ変換」してJPEGにしてください.画像サイズは「実際のサイズ」で良いかと思います.
以下のプログラムは,macOS Montereyで動作確認していますが,RICOH THETAで撮影した360度写真と共に写真アルバムを作るにあるPannellumを使っています.
作ったものはこんな感じ.
サンライズ出雲乗車記録2026.04.09_150分おくれ
作ったものはこんな感じ.
サンライズ出雲乗車記録2026.04.09_150分おくれ
$ cat generate_viewer.py🆑
#!/usr/bin/env python3
import argparse
from pathlib import Path
import subprocess
import json
from datetime import datetime, timezone, timedelta
import urllib.request
import urllib.parse
from collections import OrderedDict
# ==========================
# 共通
# ==========================
created_date = datetime.now().strftime("%Y年%m月%d日")
geocode_cache = {}
JST = timezone(timedelta(hours=9))
# ==========================
# 逆ジオコーディング
# ==========================
def reverse_geocode(lat, lon):
key = f"{lat},{lon}"
if key in geocode_cache:
return geocode_cache[key]
try:
url = "https://nominatim.openstreetmap.org/reverse?" + urllib.parse.urlencode({
"lat": lat,
"lon": lon,
"format": "json",
"zoom": 16,
"addressdetails": 1
})
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=5) as res:
data = json.loads(res.read().decode("utf-8"))
addr = data.get("address", {})
geocode_cache[key] = addr
return addr
except:
geocode_cache[key] = {}
return {}
def extract_city(addr):
return (
addr.get("city") or
addr.get("town") or
addr.get("village") or
addr.get("municipality") or
addr.get("ward") or
addr.get("city_district") or
addr.get("county") or
addr.get("suburb") or
""
)
def format_location_short(addr):
if not addr:
return "ー"
state = addr.get("state", "")
city = extract_city(addr)
if state and city:
return f"{state} {city}"
elif state:
return state
elif city:
return city
else:
return "ー"
def format_location_full(addr):
if not addr:
return "ー"
state = addr.get("state", "")
city = extract_city(addr)
detail_parts = []
for key in ["suburb", "neighbourhood", "hamlet", "quarter", "road", "house_number"]:
if addr.get(key):
detail_parts.append(addr[key])
detail = " ".join(detail_parts)
if state and city:
base = f"{state} {city}".strip()
elif state:
base = state
elif city:
base = city
else:
base = ""
if base and detail:
return f"{base} {detail}".strip()
elif base:
return base
elif detail:
return detail
else:
return "ー"
# ==========================
# 日時関連
# ==========================
def parse_dt(dt_str):
try:
return datetime.strptime(dt_str, "%Y:%m:%d %H:%M:%S")
except:
return None
def format_datetime(dt_str):
try:
dt = datetime.strptime(dt_str, "%Y:%m:%d %H:%M:%S")
return dt.strftime("%Y年%m月%d日 %H:%M:%S")
except:
return "ー"
def parse_creation_time(s):
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except:
return None
def format_datetime_from_dt(dt):
if not dt:
return "ー"
return dt.strftime("%Y年%m月%d日 %H:%M:%S")
def format_date_only_from_dt(dt):
if not dt:
return "その他"
return dt.strftime("%Y年%m月%d日")
# ==========================
# EXIF / 動画メタデータ
# ==========================
def get_exif(path):
result = subprocess.run(
["exiftool", "-json", "-n", str(path)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
return json.loads(result.stdout)[0]
def get_video_metadata(path):
cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", str(path)
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, text=True)
if not result.stdout.strip():
return {}
return json.loads(result.stdout)
def parse_video_location(loc):
if not loc:
return None, None
try:
loc = loc.strip("/")
if loc[0] in "+-":
sign = loc[0]
rest = loc[1:]
parts = rest.split("+")
if len(parts) >= 2:
lat = float(sign + parts[0])
lon = float("+" + parts[1])
return lat, lon
except:
pass
return None, None
def format_duration(sec):
try:
sec = int(float(sec))
m = sec // 60
s = sec % 60
return f"{m:02d}:{s:02d}"
except:
return "ー"
# ==========================
# HTML テンプレート
# ==========================
INDEX_HEAD = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>{page_title}</title>
<style>
body {{
font-family: sans-serif;
background: #fafafa;
margin: 0;
}}
header {{
text-align: center;
padding: 20px;
}}
h1 {{
margin: 10px 0 0 0;
font-size: 26px;
}}
h3 {{
margin: 5px 0 10px 0;
font-size: 16px;
color: #555;
}}
.topnav {{
text-align: center;
margin: 10px 0;
font-size: 18px;
}}
.section-title {{
max-width: 1200px;
margin: 10px auto 0 auto;
padding: 0 20px;
font-size: 18px;
font-weight: bold;
}}
.count-info {{
text-align: center;
font-size: 14px;
color: #555;
margin-bottom: 10px;
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
padding: 10px 20px 20px 20px;
max-width: 1200px;
margin: 0 auto 20px auto;
}}
.card {{
background: white;
border-radius: 8px;
padding: 10px;
box-shadow: 0 0 6px rgba(0,0,0,0.2);
text-align: center;
}}
.thumb-img {{
width: 100%;
height: auto;
border-radius: 6px;
}}
.filename {{
text-align: left;
font-size: 14px;
margin-top: 5px;
}}
.datetime {{
text-align: left;
font-size: 12px;
color: #666;
}}
.location {{
text-align: left;
font-size: 12px;
color: #444;
margin-top: 3px;
}}
.map-link {{
text-align: left;
font-size: 12px;
margin-top: 3px;
}}
.map-link a {{
text-decoration: none;
color: #1a73e8;
}}
.badge {{
display: inline-block;
padding: 2px 6px;
font-size: 10px;
border-radius: 4px;
background: #eee;
margin-left: 4px;
}}
.badge-360 {{
background: #4caf50;
color: #fff;
}}
.badge-video {{
background: #f44336;
color: #fff;
}}
</style>
</head>
<body>
<div class="topnav">
<a href="../index.html">↑ 1つ上に戻る</a>
</div>
<header>
<hr>
<h1>{page_title}</h1>
<h3>作成日:{created_date}</h3>
<div class="count-info">全 {total_count} 件</div>
<hr>
</header>
"""
INDEX_FOOT = """
</body>
</html>
"""
VIEWER_HEAD = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<link rel="stylesheet" href="../pannellum.css">
<script src="../pannellum.js"></script>
<style>
body {{
margin: 0;
font-family: sans-serif;
background: #fafafa;
}}
header {{
text-align: center;
padding: 20px;
}}
h1 {{
margin: 10px 0 0 0;
font-size: 26px;
}}
h3 {{
margin: 5px 0 10px 0;
font-size: 16px;
color: #555;
}}
.pano {{
width: 100%;
height: 500px;
}}
.info-table {{
margin: 20px auto;
width: 90%;
max-width: 700px;
border-collapse: collapse;
}}
.info-table td {{
border: 1px solid #ccc;
padding: 8px;
}}
.info-table td:first-child {{
width: 30%;
background: #f0f0f0;
font-weight: bold;
}}
.nav {{
margin: 20px 0;
font-size: 22px;
text-align: center;
}}
.nav a {{
margin: 0 25px;
text-decoration: none;
}}
.map-btn {{
display: inline-block;
padding: 10px 20px;
background: #4285F4;
color: white;
border-radius: 6px;
text-decoration: none;
font-size: 16px;
}}
</style>
</head>
<body>
<header>
<hr>
<h1>{title}</h1>
<h3>作成日:{created_date}</h3>
<hr>
</header>
<div class="nav">
{nav_top}
</div>
"""
VIEWER_FOOT = """
</body>
</html>
"""
# ==========================
# メイン処理
# ==========================
def main():
parser = argparse.ArgumentParser(description="THETA 360° / 写真 / 動画 ビューア生成(HEIC変換 + 動画メタデータ + JST対応)")
parser.add_argument("-f", "--folder", required=True)
parser.add_argument("-o", "--output", required=True)
parser.add_argument("-t", "--title", required=True)
args = parser.parse_args()
folder = Path(args.folder).resolve()
image_ext = {".jpg", ".jpeg", ".JPG", ".JPEG", ".png", ".PNG", ".heic", ".HEIC"}
video_ext = {".mp4", ".mov", ".m4v", ".webm", ".MP4", ".MOV", ".M4V", ".WEBM"}
files = []
for p in sorted(folder.iterdir()):
if p.suffix in image_ext or p.suffix in video_ext:
files.append(p)
items = []
# ==========================
# ファイルごとの処理
# ==========================
for f in files:
ext = f.suffix
lower_ext = f.suffix.lower()
is_video = lower_ext in {e.lower() for e in video_ext}
is_heic = lower_ext in {".heic"}
# HEIC → JPEG 変換(元ファイルは残す)
if is_heic:
jpg_path = f.with_suffix(".jpg")
if not jpg_path.exists():
subprocess.run(
["ffmpeg", "-y", "-i", str(f), str(jpg_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
f = jpg_path
lower_ext = ".jpg"
is_video = False
exif = {}
dt = None
dt_raw = None
camera_model = "ー"
lat = None
lon = None
projection = None
video_meta = {}
video_duration = "ー"
video_codec = "ー"
video_resolution = "ー"
# ==========================
# 写真(EXIF)
# ==========================
if not is_video:
exif = get_exif(f)
dt_raw = exif.get("DateTimeOriginal")
dt = parse_dt(dt_raw) if dt_raw else None
camera_model = exif.get("Model", "ー")
lat = exif.get("GPSLatitude")
lon = exif.get("GPSLongitude")
projection = exif.get("ProjectionType")
# ==========================
# 動画(ffprobe)
# ==========================
else:
video_meta = get_video_metadata(f)
fmt = video_meta.get("format", {})
streams = video_meta.get("streams", [])
tags = fmt.get("tags", {}) if isinstance(fmt, dict) else {}
creation_time = tags.get("creation_time")
dt = parse_creation_time(creation_time)
# ★ UTC → JST → naive
if dt and dt.tzinfo:
dt = dt.astimezone(JST)
if dt:
dt = dt.replace(tzinfo=None)
dt_raw = creation_time
camera_model = tags.get("com.apple.quicktime.model") or tags.get("model") or "ー"
loc = tags.get("location")
if loc:
lat, lon = parse_video_location(loc)
dur = fmt.get("duration")
if dur:
video_duration = format_duration(dur)
if streams:
vstream = None
for s in streams:
if s.get("codec_type") == "video":
vstream = s
break
if not vstream:
vstream = streams[0]
codec_name = vstream.get("codec_name")
if codec_name:
video_codec = codec_name
w = vstream.get("width")
h = vstream.get("height")
if w and h:
video_resolution = f"{w}×{h}"
items.append({
"path": f,
"is_video": is_video,
"exif": exif,
"dt_raw": dt_raw,
"dt": dt,
"camera_model": camera_model,
"lat": lat,
"lon": lon,
"projection": projection,
"video_duration": video_duration,
"video_codec": video_codec,
"video_resolution": video_resolution,
})
# ==========================
# 日付順にソート
# ==========================
items.sort(key=lambda x: x["dt"] if x["dt"] else datetime.max)
# ==========================
# サムネイル生成フォルダ
# ==========================
thumb_dir = folder / "thumb"
thumb_dir.mkdir(exist_ok=True)
index_html = folder / args.output
total_count = len(items)
index_parts = [
INDEX_HEAD.format(
page_title=args.title,
created_date=created_date,
total_count=total_count
)
]
# ==========================
# 日付グループ化
# ==========================
groups = OrderedDict()
for idx, item in enumerate(items):
dt = item["dt"]
if dt:
date_key = dt.strftime("%Y-%m-%d")
date_label = format_date_only_from_dt(dt)
else:
date_key = "other"
date_label = "その他"
if date_key not in groups:
groups[date_key] = {"label": date_label, "items": []}
groups[date_key]["items"].append((idx, item))
total = len(items)
# ==========================
# 個別ページ生成
# ==========================
for i, item in enumerate(items):
f = item["path"]
is_video = item["is_video"]
dt = item["dt"]
dt_raw = item["dt_raw"]
camera_model = item["camera_model"]
lat = item["lat"]
lon = item["lon"]
projection = item["projection"]
video_duration = item["video_duration"]
video_codec = item["video_codec"]
video_resolution = item["video_resolution"]
viewer_name = f"viewer_{i+1:04d}.html"
viewer_path = folder / viewer_name
# --------------------------
# サムネイル生成
# --------------------------
thumb_name = f"thumb_{i+1:04d}.jpg"
thumb_path = thumb_dir / thumb_name
if not thumb_path.exists():
if not is_video:
subprocess.run(
["convert", str(f), "-resize", "300x300", str(thumb_path)]
)
else:
subprocess.run(
[
"ffmpeg", "-y",
"-i", str(f),
"-ss", "00:00:01",
"-vframes", "1",
"-vf", "scale=300:-1",
str(thumb_path)
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# --------------------------
# 日付表示
# --------------------------
if dt_raw and isinstance(dt_raw, str) and ":" in dt_raw and dt_raw.count(":") == 2 and "T" not in dt_raw:
dt_html_full = format_datetime(dt_raw)
else:
dt_html_full = format_datetime_from_dt(dt)
# --------------------------
# 位置情報
# --------------------------
if lat and lon:
addr = reverse_geocode(lat, lon)
location_short = format_location_short(addr)
location_full = format_location_full(addr)
lat_str = str(lat)
lon_str = str(lon)
else:
location_short = "ー"
location_full = "ー"
lat_str = ""
lon_str = ""
# --------------------------
# ナビゲーション
# --------------------------
prev_link = f"viewer_{i:04d}.html" if i > 0 else ""
next_link = f"viewer_{i+2:04d}.html" if i < total - 1 else ""
nav_html = ""
nav_html += f'<a href="{prev_link}">←</a>' if prev_link else '←'
nav_html += f'<a href="{args.output}">TOPに戻る</a>'
nav_html += f'<a href="{next_link}">→</a>' if next_link else '→'
prev_js = f'window.location.href="{prev_link}";' if prev_link else ''
next_js = f'window.location.href="{next_link}";' if next_link else ''
comment_name = f.stem
# --------------------------
# Google Map ボタン
# --------------------------
if lat_str and lon_str:
map_button_html = f"""
<div style="text-align:center; margin:20px;">
<a class="map-btn" href="https://www.google.com/maps?q={lat_str},{lon_str}" target="_blank">
Google Map で開く
</a>
</div>
"""
else:
map_button_html = ""
# --------------------------
# 表示方式(360° / 写真 / 動画)
# --------------------------
ext = f.suffix.lower()
video_ext_lower = {".mp4", ".mov", ".m4v", ".webm"}
if projection == "equirectangular" and not is_video:
viewer_html = f"""
<div id="panorama" class="pano"></div>
<script>
pannellum.viewer('panorama', {{
type: 'equirectangular',
panorama: '{f.name}',
autoLoad: true,
hfov: 120
}});
</script>
"""
elif ext in video_ext_lower:
viewer_html = f"""
<div style="text-align:center; margin:20px;">
<video controls style="max-width:100%; border-radius:8px;">
<source src="{f.name}" type="video/mp4">
お使いのブラウザは動画再生に対応していません。
</video>
</div>
"""
else:
viewer_html = f"""
<div style="text-align:center; margin:20px;">
<img src="{f.name}" style="max-width:100%; height:auto; border-radius:8px;">
</div>
"""
# --------------------------
# 個別ページ HTML 書き込み
# --------------------------
with open(viewer_path, "w", encoding="utf-8") as vf:
vf.write(VIEWER_HEAD.format(
title=args.title,
created_date=created_date,
nav_top=nav_html
))
vf.write(viewer_html)
vf.write(f"""
<script>
document.addEventListener('keydown', function(e) {{
if (e.key === "ArrowLeft") {{
{prev_js}
}}
if (e.key === "ArrowRight") {{
{next_js}
}}
}});
</script>
<table class="info-table">
<tr><td>撮影日</td><td>{dt_html_full}</td></tr>
<tr><td>撮影場所</td><td>{location_full}</td></tr>
<tr><td>カメラ</td><td>{camera_model}</td></tr>
<tr><td>コメント</td><td>{comment_name}</td></tr>
""")
if is_video:
vf.write(f"""
<tr><td>動画の長さ</td><td>{video_duration}</td></tr>
<tr><td>コーデック</td><td>{video_codec}</td></tr>
<tr><td>解像度</td><td>{video_resolution}</td></tr>
""")
vf.write("</table>\n")
vf.write(map_button_html)
vf.write(f"""
<div class="nav">
{nav_html}
</div>
""")
vf.write(VIEWER_FOOT)
# --------------------------
# 一覧ページ用データ保存
# --------------------------
item["viewer_name"] = viewer_name
item["thumb_name"] = thumb_name
item["dt_html_full"] = dt_html_full
item["location_short"] = location_short
item["lat_str"] = lat_str
item["lon_str"] = lon_str
if projection == "equirectangular" and not is_video:
item["badge"] = "360"
elif is_video:
item["badge"] = "video"
else:
item["badge"] = "photo"
# ==========================
# 一覧ページ生成
# ==========================
for date_key, group in groups.items():
label = group["label"]
index_parts.append(f'<div class="section-title">{label}</div>\n')
index_parts.append('<div class="grid">\n')
for idx, _ in group["items"]:
img_item = items[idx]
viewer_name = img_item["viewer_name"]
thumb_name = img_item["thumb_name"]
dt_html_full = img_item["dt_html_full"]
location_short = img_item["location_short"]
lat_str = img_item["lat_str"]
lon_str = img_item["lon_str"]
short_comment = img_item["path"].stem
if len(short_comment) > 15:
short_comment = short_comment[:15] + "…"
if lat_str and lon_str:
map_link_html = f'<div class="map-link"><a href="https://www.google.com/maps?q={lat_str},{lon_str}" target="_blank">Google Map</a></div>'
else:
map_link_html = ""
if img_item["badge"] == "360":
badge_html = '<span class="badge badge-360">360°</span>'
elif img_item["badge"] == "video":
badge_html = '<span class="badge badge-video">動画</span>'
else:
badge_html = '<span class="badge">写真</span>'
index_parts.append(f"""
<div class="card">
<a href="{viewer_name}">
<img class="thumb-img" src="thumb/{thumb_name}" alt="{img_item['path'].name}">
</a>
<div class="datetime">{dt_html_full}</div>
<div class="location">{location_short} {badge_html}</div>
{map_link_html}
<div class="filename">{short_comment}</div>
</div>
""")
index_parts.append("</div>\n")
index_parts.append(INDEX_FOOT)
# ==========================
# 一覧ページ書き込み
# ==========================
with open(index_html, "w", encoding="utf-8") as f:
f.write("".join(index_parts))
print("サムネイル・一覧ページ:", index_html)
print("個別ビューア / プレイヤー:", total_count, "件生成しました。")
if __name__ == "__main__":
main()
