選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

873 行
29 KiB

  1. #!/usr/bin/env python3
  2. # Archives iPhone photos from a local unencrypted backup and generates an HTML gallery.
  3. import argparse
  4. import os
  5. import shutil
  6. import sqlite3
  7. from pathlib import Path
  8. from datetime import datetime
  9. from collections import defaultdict
  10. import struct
  11. def read_exif_date(file_path):
  12. """Simple EXIF reader to extract date information from image files."""
  13. try:
  14. with open(file_path, 'rb') as f:
  15. # Read file header to determine format
  16. header = f.read(12)
  17. f.seek(0)
  18. if header.startswith(b'\xff\xe1') and b'Exif' in header:
  19. # JPEG with EXIF
  20. return _read_jpeg_exif_date(f)
  21. elif header.startswith(b'\xff\xd8'):
  22. # JPEG - scan for EXIF segment
  23. return _scan_jpeg_exif_date(f)
  24. elif header[4:8] == b'ftyp':
  25. # HEIC format - basic attempt
  26. return _read_heic_exif_date(f)
  27. elif header.startswith(b'\x89PNG'):
  28. # PNG format
  29. return _read_png_exif_date(f)
  30. except Exception:
  31. pass
  32. return None
  33. def _read_jpeg_exif_date(f):
  34. """Read EXIF date from JPEG file."""
  35. f.seek(0)
  36. # Find EXIF segment
  37. while True:
  38. marker = f.read(2)
  39. if not marker or marker[0] != 0xff:
  40. break
  41. if marker == b'\xff\xe1': # APP1 segment (EXIF)
  42. length = struct.unpack('>H', f.read(2))[0]
  43. exif_data = f.read(length - 2)
  44. if exif_data.startswith(b'Exif\x00\x00'):
  45. return _parse_exif_data(exif_data[6:])
  46. else:
  47. # Skip other segments
  48. if marker[1] in [0xd8, 0xd9]: # SOI, EOI
  49. continue
  50. try:
  51. length = struct.unpack('>H', f.read(2))[0]
  52. f.seek(length - 2, 1)
  53. except:
  54. break
  55. return None
  56. def _scan_jpeg_exif_date(f):
  57. """Scan JPEG file for EXIF segment."""
  58. f.seek(0)
  59. data = f.read(65536) # Read first 64KB
  60. # Look for EXIF marker
  61. exif_pos = data.find(b'Exif\x00\x00')
  62. if exif_pos > 0:
  63. return _parse_exif_data(data[exif_pos + 6:])
  64. return None
  65. def _read_heic_exif_date(f):
  66. """Basic HEIC EXIF reading - simplified approach."""
  67. f.seek(0)
  68. # Read a larger chunk to find EXIF data
  69. data = f.read(2 * 1024 * 1024) # 2MB should be enough for metadata
  70. # Look for EXIF marker in HEIC - try multiple patterns
  71. patterns = [b'Exif\x00\x00', b'Exif\x00\x01', b'EXIF\x00\x00']
  72. for pattern in patterns:
  73. exif_pos = data.find(pattern)
  74. if exif_pos >= 0:
  75. # Try to parse EXIF data starting after the marker
  76. try:
  77. result = _parse_exif_data(data[exif_pos + len(pattern):])
  78. if result:
  79. return result
  80. except:
  81. continue
  82. # Alternative: look for datetime strings directly in the file
  83. return _scan_for_datetime_strings(data)
  84. def _scan_for_datetime_strings(data):
  85. """Scan binary data for datetime strings."""
  86. import re
  87. try:
  88. # Convert to string for regex search, ignoring decode errors
  89. text = data.decode('ascii', errors='ignore')
  90. # Look for datetime patterns like "2024:08:15 14:30:45"
  91. datetime_pattern = r'20\d{2}:\d{2}:\d{2}\s+\d{2}:\d{2}:\d{2}'
  92. matches = re.findall(datetime_pattern, text)
  93. if matches:
  94. # Return the first valid datetime found
  95. return matches[0]
  96. except:
  97. pass
  98. return None
  99. def _read_png_exif_date(f):
  100. """Read EXIF date from PNG file."""
  101. f.seek(8) # Skip PNG signature
  102. while True:
  103. try:
  104. # Read chunk length and type
  105. length_data = f.read(4)
  106. if len(length_data) != 4:
  107. break
  108. length = struct.unpack('>I', length_data)[0]
  109. chunk_type = f.read(4)
  110. if len(chunk_type) != 4:
  111. break
  112. if chunk_type == b'eXIf':
  113. # PNG EXIF chunk - contains standard EXIF data
  114. exif_data = f.read(length)
  115. return _parse_exif_data(exif_data)
  116. elif chunk_type == b'iTXt':
  117. # International text chunk - might contain date
  118. chunk_data = f.read(length)
  119. try:
  120. # iTXt format: keyword\0compression\0language\0translated_keyword\0text
  121. parts = chunk_data.split(b'\0', 4)
  122. if len(parts) >= 5:
  123. keyword = parts[0].decode('latin-1', errors='ignore')
  124. text = parts[4].decode('utf-8', errors='ignore')
  125. # Look for date-related keywords
  126. if keyword.lower() in ['date', 'datetime', 'creation time', 'date:create', 'exif:datetime']:
  127. # Try to parse as datetime
  128. import re
  129. datetime_match = re.search(r'20\d{2}[:-]\d{2}[:-]\d{2}[\sT]\d{2}:\d{2}:\d{2}', text)
  130. if datetime_match:
  131. date_str = datetime_match.group()
  132. # Convert to EXIF format
  133. date_str = date_str.replace('-', ':').replace('T', ' ')
  134. return date_str
  135. except:
  136. pass
  137. elif chunk_type == b'tEXt':
  138. # Text chunk - might contain date
  139. chunk_data = f.read(length)
  140. try:
  141. # tEXt format: keyword\0text
  142. null_pos = chunk_data.find(b'\0')
  143. if null_pos > 0:
  144. keyword = chunk_data[:null_pos].decode('latin-1', errors='ignore')
  145. text = chunk_data[null_pos+1:].decode('latin-1', errors='ignore')
  146. if keyword.lower() in ['date', 'creation time', 'timestamp']:
  147. import re
  148. datetime_match = re.search(r'20\d{2}[:-]\d{2}[:-]\d{2}[\sT]\d{2}:\d{2}:\d{2}', text)
  149. if datetime_match:
  150. date_str = datetime_match.group()
  151. date_str = date_str.replace('-', ':').replace('T', ' ')
  152. return date_str
  153. except:
  154. pass
  155. else:
  156. # Skip other chunk types
  157. f.seek(length, 1)
  158. # Skip CRC
  159. f.seek(4, 1)
  160. except (struct.error, OSError):
  161. break
  162. return None
  163. def _parse_exif_data(exif_data):
  164. """Parse EXIF data to extract date tags."""
  165. if len(exif_data) < 8:
  166. return None
  167. try:
  168. # Check byte order
  169. if exif_data[:2] == b'II':
  170. endian = '<' # Little endian
  171. elif exif_data[:2] == b'MM':
  172. endian = '>' # Big endian
  173. else:
  174. return None
  175. # Get IFD offset
  176. ifd_offset = struct.unpack(endian + 'I', exif_data[4:8])[0]
  177. if ifd_offset >= len(exif_data):
  178. return None
  179. # Read IFD entries
  180. date_tags = {
  181. 0x9003: 'DateTimeOriginal', # EXIF DateTimeOriginal
  182. 0x0132: 'DateTime', # Image DateTime
  183. 0x9004: 'DateTimeDigitized', # EXIF DateTimeDigitized
  184. 0x0306: 'DateTime', # Additional DateTime tag
  185. }
  186. # Try to find date in IFD0
  187. date_value = _read_ifd_dates(exif_data, ifd_offset, endian, date_tags)
  188. if date_value:
  189. return date_value
  190. # Try EXIF sub-IFD if available
  191. exif_ifd_offset = _find_exif_ifd(exif_data, ifd_offset, endian)
  192. if exif_ifd_offset and exif_ifd_offset < len(exif_data):
  193. date_value = _read_ifd_dates(exif_data, exif_ifd_offset, endian, date_tags)
  194. if date_value:
  195. return date_value
  196. # Try IFD1 (thumbnail) if available
  197. ifd1_offset = _get_next_ifd(exif_data, ifd_offset, endian)
  198. if ifd1_offset and ifd1_offset < len(exif_data):
  199. date_value = _read_ifd_dates(exif_data, ifd1_offset, endian, date_tags)
  200. if date_value:
  201. return date_value
  202. except Exception:
  203. pass
  204. return None
  205. def _read_ifd_dates(exif_data, ifd_offset, endian, date_tags):
  206. """Read date tags from IFD."""
  207. try:
  208. if ifd_offset + 2 >= len(exif_data):
  209. return None
  210. entry_count = struct.unpack(endian + 'H', exif_data[ifd_offset:ifd_offset + 2])[0]
  211. for i in range(entry_count):
  212. entry_offset = ifd_offset + 2 + (i * 12)
  213. if entry_offset + 12 > len(exif_data):
  214. break
  215. tag, tag_type, count, value_offset = struct.unpack(
  216. endian + 'HHII', exif_data[entry_offset:entry_offset + 12]
  217. )
  218. if tag in date_tags:
  219. # Handle ASCII string (type 2)
  220. if tag_type == 2:
  221. if count <= 4:
  222. # Value stored in value_offset field
  223. value_data = struct.pack(endian + 'I', value_offset)[:count-1]
  224. else:
  225. # Value stored at offset
  226. if value_offset + count <= len(exif_data):
  227. value_data = exif_data[value_offset:value_offset + count - 1]
  228. else:
  229. continue
  230. try:
  231. date_str = value_data.decode('ascii')
  232. if len(date_str) >= 19 and ':' in date_str: # "YYYY:MM:DD HH:MM:SS"
  233. return date_str
  234. except:
  235. continue
  236. # Handle other types that might contain date strings
  237. elif tag_type in [1, 3, 4, 5]: # BYTE, SHORT, LONG, RATIONAL
  238. try:
  239. if count <= 4:
  240. # Data stored inline
  241. raw_data = struct.pack(endian + 'I', value_offset)
  242. else:
  243. # Data stored at offset
  244. if value_offset + count * 4 <= len(exif_data):
  245. raw_data = exif_data[value_offset:value_offset + min(count * 4, 20)]
  246. else:
  247. continue
  248. # Try to decode as ASCII
  249. try:
  250. potential_date = raw_data.decode('ascii', errors='ignore').rstrip('\x00')
  251. if len(potential_date) >= 19 and ':' in potential_date:
  252. return potential_date
  253. except:
  254. pass
  255. except:
  256. continue
  257. except Exception:
  258. pass
  259. return None
  260. def _get_next_ifd(exif_data, ifd_offset, endian):
  261. """Get the offset of the next IFD."""
  262. try:
  263. if ifd_offset + 2 >= len(exif_data):
  264. return None
  265. entry_count = struct.unpack(endian + 'H', exif_data[ifd_offset:ifd_offset + 2])[0]
  266. next_ifd_offset_pos = ifd_offset + 2 + (entry_count * 12)
  267. if next_ifd_offset_pos + 4 <= len(exif_data):
  268. next_ifd_offset = struct.unpack(endian + 'I', exif_data[next_ifd_offset_pos:next_ifd_offset_pos + 4])[0]
  269. return next_ifd_offset if next_ifd_offset > 0 else None
  270. except Exception:
  271. pass
  272. return None
  273. def _find_exif_ifd(exif_data, ifd_offset, endian):
  274. """Find EXIF sub-IFD offset."""
  275. try:
  276. if ifd_offset + 2 >= len(exif_data):
  277. return None
  278. entry_count = struct.unpack(endian + 'H', exif_data[ifd_offset:ifd_offset + 2])[0]
  279. for i in range(entry_count):
  280. entry_offset = ifd_offset + 2 + (i * 12)
  281. if entry_offset + 12 > len(exif_data):
  282. break
  283. tag, tag_type, count, value_offset = struct.unpack(
  284. endian + 'HHII', exif_data[entry_offset:entry_offset + 12]
  285. )
  286. if tag == 0x8769: # EXIF IFD tag
  287. return value_offset
  288. except Exception:
  289. pass
  290. return None
  291. def copy_camera_roll(backup_path: Path, output_path: Path):
  292. manifest_db = backup_path / "Manifest.db"
  293. if not manifest_db.exists():
  294. raise FileNotFoundError(f"Manifest.db not found in {backup_path}")
  295. conn = sqlite3.connect(manifest_db)
  296. cursor = conn.cursor()
  297. # Query all files from CameraRollDomain
  298. cursor.execute("""
  299. SELECT fileID, relativePath
  300. FROM Files
  301. WHERE domain = 'CameraRollDomain'
  302. """)
  303. rows = cursor.fetchall()
  304. print(f"Found {len(rows)} CameraRollDomain files")
  305. for file_id, relative_path in rows:
  306. # FileID is stored as 40-char hex. Backup stores it as <first 2>/<full>
  307. src = backup_path / file_id[:2] / file_id
  308. if not src.exists():
  309. print(f"⚠️ Missing file: {src}")
  310. continue
  311. dest = output_path / relative_path
  312. dest.parent.mkdir(parents=True, exist_ok=True)
  313. if not dest.exists():
  314. shutil.copy2(src, dest)
  315. print(f"✅ Copied {relative_path}")
  316. else:
  317. print(f"⏩ Skipped (already exists): {relative_path}")
  318. conn.close()
  319. print("🎉 Backup extraction completed.")
  320. def find_display_file(original_file, metadata_dcim, thumbnails_dcim):
  321. """Find the best display file (metadata JPG or thumbnail) for an original file."""
  322. base_name = original_file.stem # e.g., "IMG_1105"
  323. # First try to find in metadata
  324. if metadata_dcim.exists():
  325. for folder in metadata_dcim.iterdir():
  326. if folder.is_dir():
  327. metadata_jpg = folder / f"{base_name}.JPG"
  328. if metadata_jpg.exists():
  329. return metadata_jpg, "metadata"
  330. # Fallback to thumbnails - each image has its own directory named with full filename
  331. if thumbnails_dcim.exists():
  332. for dcim_folder in thumbnails_dcim.iterdir():
  333. if dcim_folder.is_dir():
  334. # Look for a directory named after the full original filename
  335. image_dir = dcim_folder / original_file.name
  336. if image_dir.exists() and image_dir.is_dir():
  337. # Find the JPG file inside this directory (usually numbered like 5003.JPG)
  338. for jpg_file in image_dir.glob("*.JPG"):
  339. return jpg_file, "thumbnail"
  340. # If no display file found, use original
  341. return original_file, "original"
  342. def get_all_original_files(original_dcim):
  343. """Get all original image/video files from DCIM folders."""
  344. original_files = []
  345. for folder in original_dcim.iterdir():
  346. if not folder.is_dir():
  347. continue
  348. for ext in ['.HEIC', '.JPG', '.PNG', '.MOV', '.MP4', '.JPEG']:
  349. for file_path in folder.glob(f"*{ext}"):
  350. original_files.append(file_path)
  351. return original_files
  352. def get_file_info(file_path):
  353. """Get file information including size, modification time, and date taken from EXIF."""
  354. stat = file_path.stat()
  355. # Try to get date taken from EXIF data using our custom reader
  356. date_taken = None
  357. date_taken_obj = None
  358. if file_path.suffix.lower() in ['.jpg', '.jpeg', '.heic', '.png']:
  359. try:
  360. exif_date = read_exif_date(file_path)
  361. if exif_date:
  362. try:
  363. date_taken_obj = datetime.strptime(exif_date, '%Y:%m:%d %H:%M:%S')
  364. date_taken = date_taken_obj.strftime('%Y-%m-%d %H:%M:%S')
  365. except ValueError:
  366. pass
  367. except Exception:
  368. pass # Ignore errors reading EXIF data
  369. # Fallback to file modification time if no EXIF date
  370. if not date_taken_obj:
  371. date_taken_obj = datetime.fromtimestamp(stat.st_mtime)
  372. date_taken = date_taken_obj.strftime('%Y-%m-%d %H:%M:%S')
  373. return {
  374. 'size': stat.st_size,
  375. 'date_taken': date_taken,
  376. 'date_taken_obj': date_taken_obj,
  377. 'size_mb': round(stat.st_size / (1024 * 1024), 2)
  378. }
  379. def generate_gallery(photos_root: Path):
  380. """Generate the HTML image gallery."""
  381. html_view = photos_root / "html_view"
  382. # Paths for different file types
  383. metadata_dcim = photos_root / "Media" / "PhotoData" / "Metadata" / "DCIM"
  384. thumbnails_dcim = photos_root / "Media" / "PhotoData" / "Thumbnails" / "V2" / "DCIM"
  385. original_dcim = photos_root / "Media" / "DCIM"
  386. if not original_dcim.exists():
  387. print(f"❌ Original DCIM folder not found: {original_dcim}")
  388. return
  389. print(f"📁 Looking for display files in:")
  390. print(f" Metadata: {metadata_dcim.exists() and 'Found' or 'Not found'}")
  391. print(f" Thumbnails: {thumbnails_dcim.exists() and 'Found' or 'Not found'}")
  392. # Get all original files
  393. original_files = get_all_original_files(original_dcim)
  394. print(f"Found {len(original_files)} original files")
  395. if not original_files:
  396. print("❌ No images found to generate gallery")
  397. return
  398. # Collect all images
  399. images = []
  400. metadata_count = 0
  401. thumbnail_count = 0
  402. original_only_count = 0
  403. for original_file in original_files:
  404. # Find the best display file
  405. display_file, display_type = find_display_file(original_file, metadata_dcim, thumbnails_dcim)
  406. # Count display types
  407. if display_type == "metadata":
  408. metadata_count += 1
  409. elif display_type == "thumbnail":
  410. thumbnail_count += 1
  411. else:
  412. original_only_count += 1
  413. # Get file info
  414. original_info = get_file_info(original_file)
  415. display_info = get_file_info(display_file) if display_file != original_file else original_info
  416. # Get folder name from original file path
  417. folder_name = original_file.parent.name
  418. images.append({
  419. 'name': original_file.stem,
  420. 'display_path': str(display_file.relative_to(photos_root)),
  421. 'original_path': str(original_file.relative_to(photos_root)),
  422. 'folder': folder_name,
  423. 'display_info': display_info,
  424. 'original_info': original_info,
  425. 'original_ext': original_file.suffix.upper(),
  426. 'display_type': display_type,
  427. 'display_ext': display_file.suffix.upper()
  428. })
  429. print(f"📊 Total original files: {len(original_files)}")
  430. print(f"📊 Using metadata for display: {metadata_count}")
  431. print(f"📊 Using thumbnails for display: {thumbnail_count}")
  432. print(f"📊 Using original for display: {original_only_count}")
  433. print(f"📊 Images to display: {len(images)}")
  434. # Sort images by date taken (newest first), then by name
  435. images.sort(key=lambda x: (x['original_info']['date_taken_obj'], x['name']), reverse=True)
  436. # Group images by date
  437. grouped_images = defaultdict(list)
  438. for img in images:
  439. date_key = img['original_info']['date_taken_obj'].strftime('%Y-%m-%d')
  440. grouped_images[date_key].append(img)
  441. # Generate HTML content
  442. html_content = generate_html_content(images, grouped_images)
  443. # Write the HTML file
  444. html_view.mkdir(exist_ok=True)
  445. output_file = html_view / "index.html"
  446. with open(output_file, 'w', encoding='utf-8') as f:
  447. f.write(html_content)
  448. print(f"✅ Gallery generated: {output_file}")
  449. print(f"📊 {len(images)} images included")
  450. print(f"🌐 Open {output_file} in your browser to view the gallery")
  451. def generate_html_content(images, grouped_images):
  452. """Generate the HTML content for the gallery."""
  453. return f"""<!DOCTYPE html>
  454. <html lang="en">
  455. <head>
  456. <meta charset="UTF-8">
  457. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  458. <title>iPhone Gallery Archive</title>
  459. <style>
  460. * {{
  461. margin: 0;
  462. padding: 0;
  463. box-sizing: border-box;
  464. }}
  465. body {{
  466. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  467. background: #f5f5f5;
  468. color: #333;
  469. }}
  470. .header {{
  471. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  472. color: white;
  473. padding: 2rem;
  474. text-align: center;
  475. box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  476. }}
  477. .header h1 {{
  478. font-size: 2.5rem;
  479. margin-bottom: 0.5rem;
  480. }}
  481. .stats {{
  482. margin-top: 1rem;
  483. opacity: 0.9;
  484. }}
  485. .gallery-container {{
  486. margin: 2rem auto;
  487. padding: 0 1rem;
  488. }}
  489. .date-section {{
  490. margin-bottom: 2rem;
  491. }}
  492. .date-header {{
  493. color: #495057;
  494. border-top: 1px solid #ddd;
  495. padding: 1rem 0 0;
  496. margin-bottom: 1rem;
  497. font-size: 1rem;
  498. }}
  499. .date-subheader {{
  500. font-size: 0.9rem;
  501. opacity: 0.7;
  502. margin-top: 0.25rem;
  503. }}
  504. .gallery {{
  505. display: grid;
  506. grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  507. gap: 1rem;
  508. margin-bottom: 2rem;
  509. }}
  510. .image-card {{
  511. overflow: hidden;
  512. }}
  513. .image-card .date-taken {{
  514. margin-top: 0.5rem;
  515. font-size: 0.85rem;
  516. }}
  517. .image-card .file-path {{
  518. font-size: 0.75rem;
  519. margin-bottom: 0.25rem;
  520. word-break: break-all;
  521. }}
  522. .image-container {{
  523. position: relative;
  524. width: 100%;
  525. overflow: hidden;
  526. background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  527. background-size: 200% 100%;
  528. animation: loading 1.5s infinite;
  529. line-height: 0;
  530. border-radius: 8px;
  531. }}
  532. @keyframes loading {{
  533. 0% {{
  534. background-position: 200% 0;
  535. }}
  536. 100% {{
  537. background-position: -200% 0;
  538. }}
  539. }}
  540. .image-container img {{
  541. width: 100%;
  542. height: auto;
  543. object-fit: contain;
  544. transition: transform 0.3s ease, opacity 0.3s ease;
  545. background: transparent;
  546. line-height: 0;
  547. border-radius: 8px;
  548. }}
  549. .image-container img[loading="lazy"]:not([src]) {{
  550. opacity: 0;
  551. }}
  552. .image-container img[loading="lazy"] {{
  553. opacity: 1;
  554. }}
  555. .image-container:has(img[loading="lazy"][src]) {{
  556. background: white;
  557. animation: none;
  558. }}
  559. .image-card:hover .image-container img {{
  560. transform: scale(1.02);
  561. }}
  562. .video-indicator {{
  563. position: absolute;
  564. top: 0.5rem;
  565. right: 0.5rem;
  566. background: rgba(0, 0, 0, 0.8);
  567. color: white;
  568. padding: 0.3rem 0.5rem;
  569. border-radius: 4px;
  570. font-size: 0.75rem;
  571. font-weight: 600;
  572. display: flex;
  573. align-items: center;
  574. gap: 0.2rem;
  575. line-height: 1.2;
  576. }}
  577. .overlay {{
  578. position: absolute;
  579. top: 0;
  580. left: 0;
  581. right: 0;
  582. bottom: 0;
  583. background: linear-gradient(to bottom, transparent 40%, rgba(0,0,0,0.9));
  584. opacity: 0;
  585. transition: opacity 0.3s ease;
  586. display: flex;
  587. flex-direction: column;
  588. justify-content: flex-end;
  589. padding: 1rem;
  590. }}
  591. .image-card:hover .overlay {{
  592. opacity: 1;
  593. }}
  594. .overlay-content {{
  595. color: white;
  596. margin-bottom: 1rem;
  597. }}
  598. .overlay-content .filename {{
  599. font-weight: 600;
  600. margin-bottom: 0.5rem;
  601. }}
  602. .overlay-content .details {{
  603. opacity: 0.9;
  604. line-height: 1.4;
  605. }}
  606. .overlay-buttons {{
  607. display: flex;
  608. gap: 0.5rem;
  609. }}
  610. .btn {{
  611. padding: 0.6rem 1rem;
  612. border: none;
  613. border-radius: 6px;
  614. text-decoration: none;
  615. text-align: center;
  616. transition: all 0.3s ease;
  617. cursor: pointer;
  618. font-size: 0.9rem;
  619. width: 100%;
  620. line-height: 1.2;
  621. }}
  622. .btn-primary {{
  623. background: rgba(255, 255, 255, 0.95);
  624. color: #333;
  625. }}
  626. .btn-primary:hover {{
  627. background: white;
  628. box-shadow: 0 4px 12px rgba(0,0,0,0.2);
  629. }}
  630. .folder-badge {{
  631. display: inline-block;
  632. background: #e9ecef;
  633. color: #495057;
  634. padding: 0.2rem 0.5rem;
  635. border-radius: 12px;
  636. font-size: 0.8rem;
  637. font-weight: 600;
  638. }}
  639. @media (max-width: 768px) {{
  640. .gallery {{
  641. grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  642. gap: 0.75rem;
  643. }}
  644. .header h1 {{
  645. font-size: 2rem;
  646. }}
  647. .overlay {{
  648. padding: 0.75rem;
  649. }}
  650. }}
  651. </style>
  652. </head>
  653. <body>
  654. <div class="header">
  655. <h1>📸 iPhone Camera Roll Gallery</h1>
  656. <p>Extracted from iPhone backup</p>
  657. <div class="stats">
  658. {len(images)} photos • {len(set(img['folder'] for img in images))} folders
  659. </div>
  660. </div>
  661. <div class="gallery-container">
  662. {generate_gallery_sections(grouped_images)}
  663. </div>
  664. </body>
  665. </html>"""
  666. def generate_gallery_sections(grouped_images):
  667. """Generate HTML for gallery sections grouped by date."""
  668. sections_html = ""
  669. for date_key in sorted(grouped_images.keys(), reverse=True):
  670. date_obj = datetime.strptime(date_key, '%Y-%m-%d')
  671. date_display = date_obj.strftime('%d.%m.%Y')
  672. image_count = len(grouped_images[date_key])
  673. sections_html += f"""
  674. <div class="date-section">
  675. <div class="date-header">
  676. {date_display}
  677. <div class="date-subheader">{image_count} {'photo' if image_count == 1 else 'photos'}</div>
  678. </div>
  679. <div class="gallery">
  680. """
  681. for img in grouped_images[date_key]:
  682. # Check if it's a video file
  683. is_video = img['original_ext'].lower() in ['.mov', '.mp4']
  684. video_indicator = '<div class="video-indicator">🎬 VIDEO</div>' if is_video else ''
  685. sections_html += f"""
  686. <div class="image-card">
  687. <div class="image-container">
  688. <img src="../{img['display_path']}" alt="{img['name']}" loading="lazy" decoding="async">
  689. {video_indicator}
  690. <div class="overlay">
  691. <div class="overlay-content">
  692. <div class="details">
  693. <div class="file-path">{img['original_path']}</div>"""
  694. # Add date taken if available
  695. if img['original_info']['date_taken']:
  696. sections_html += f"""
  697. <div class="date-taken">{img['original_info']['date_taken']}</div>"""
  698. sections_html += f"""
  699. </div>
  700. </div>
  701. <div class="overlay-buttons">
  702. <a href="../{img['original_path']}" target="_blank" class="btn btn-primary">
  703. Original ({img['original_info']['size_mb']} MB)
  704. </a>
  705. </div>
  706. </div>
  707. </div>
  708. </div>
  709. """
  710. sections_html += """
  711. </div>
  712. </div>
  713. """
  714. return sections_html
  715. def main():
  716. parser = argparse.ArgumentParser(description="Extract Camera Roll from iPhone backup and optionally generate HTML gallery")
  717. parser.add_argument("--backup-path", required=True, type=Path,
  718. help="Path to iPhone backup folder (with Manifest.db)")
  719. parser.add_argument("--output-path", required=True, type=Path,
  720. help="Path where Camera Roll should be restored")
  721. parser.add_argument("--generate-gallery", action="store_true",
  722. help="Generate HTML gallery after extraction")
  723. args = parser.parse_args()
  724. # Extract camera roll
  725. copy_camera_roll(args.backup_path, args.output_path)
  726. # Generate gallery if requested
  727. if args.generate_gallery:
  728. print("\n🖼️ Generating HTML gallery...")
  729. generate_gallery(args.output_path)
  730. if __name__ == "__main__":
  731. main()