Skill: konanok/photo-toolkit (version 1.0.0)

--- name: photo-toolkit version: 1.0.0 description: | Photography utility toolkit: convert RAW/JPG/HEIC to thumbnails, find photos by shooting date, generate layout previews, deflicker timelapse frames, and assemble video. Supports all major camera brands: Nikon (NEF), Canon (CR2/CR3), Sony (ARW), Fujifilm (RAF), Olympus (ORF), Panasonic (RW2), Pentax (PEF), Samsung (SRW), Leica (DNG), Adobe (DNG), Hasselblad (3FR), Phase One (IIQ), Sigma (X3F), plus standard JPEG (.jpg/.jpeg) and Apple HEIC/HEIF (.heic/.heif). Use when the user wants to: - Convert RAW/JPG/HEIC files to JPG format (single file or batch) - Generate thumbnails / previews from camera photos - Batch process photo files in a directory (with optional recursive search) - Find / filter photos by shooting date - List all photo files grouped by shooting date - Generate layout preview grid from graded photos - Remove flicker from timelapse frame sequences (deflicker) - Assemble sequential JPG frames into MP4 video Triggers: User mentions converting RAW/NEF/CR2/ARW/RAF/JPG/HEIC to JPG, generating thumbnails, batch processing camera photos, finding photos by date, deflicker, timelapse video assembly. Dependencies: System: libraw (RedHat: dnf install LibRaw-devel / Debian: apt-get install libraw-dev) Python: rawpy, pillow, numpy, pillow-heif (optional, for HEIC/HEIF) Check: bash scripts/setup_deps.sh metadata: openclaw: homepage: https://github.com/konanok/photo-skills emoji: "📸" requires: bins: - python3 --- # Photo Toolkit Convert camera RAW files (NEF/CR2/CR3/ARW/RAF/ORF/DNG/...), JPG photos, and Apple HEIC/HEIF images to JPG thumbnails, find photos by shooting date, and generate layout previews. ## Supported Formats ### Camera RAW | Brand | Extensions | | ---------- | ---------------------- | | Nikon | `.nef`, `.nrw` | | Canon | `.cr2`, `.cr3`, `.crw` | | Sony | `.arw`, `.srf`, `.sr2` | | Fujifilm | `.raf` | | Olympus/OM | `.orf` | | Panasonic | `.rw2` | | Pentax | `.pef` | | Samsung | `.srw` | | Leica | `.rwl`, `.dng` | | Adobe DNG | `.dng` | | Hasselblad | `.3fr`, `.fff` | | Phase One | `.iiq` | | Sigma | `.x3f` | ### Standard Image | Format | Extensions | Notes | | ------ | --------------- | ---------------- | | JPEG | `.jpg`, `.jpeg` | 直接 Pillow 处理 | ### Apple (iPhone/iPad) | Format | Extensions | Notes | | --------- | ---------------- | ------------------------------ | | HEIC/HEIF | `.heic`, `.heif` | 需要 `pip install pillow-heif` | ## Dependencies **Declaration file:** `requirements.txt` **Prefer venv**: Before running scripts, activate the project-root virtual environment (e.g. `.venv/`). If it doesn't exist, create one first: ```bash # Create venv and install dependencies (recommended) python3 -m venv .venv source .venv/bin/activate pip install -r photo-toolkit/requirements.txt # Or use the skill's setup script (checks + installs) bash photo-toolkit/scripts/setup_deps.sh # Before each session, activate venv source .venv/bin/activate ``` Alternatively, install globally: ```bash brew install libraw # macOS # apt-get install libraw-dev # Debian/Ubuntu # dnf install LibRaw-devel # RedHat/CentOS/Fedora pip3 install -r photo-toolkit/requirements.txt ``` **Verify:** ```bash python3 -c "import rawpy; from PIL import Image; import numpy; print('✓ Core dependencies installed')" python3 -c "from pillow_heif import register_heif_opener; print('✓ HEIC/HEIF support available')" 2>/dev/null || echo "ℹ HEIC/HEIF support not installed (optional: pip install pillow-heif)" ``` ## Configuration Copy `config.example.toml` to `config.toml` and edit to set your directories. See `config.example.toml` for all available options. ## Scripts ### 1. `convert.py` — Photo → JPG Thumbnails Supports RAW, JPG, and HEIC/HEIF input. By default, thumbnails are output to `{input}/thumbnails/`. ```bash # Convert all photo files (thumbnails output to ~/data/RAW/thumbnails/) python3 scripts/convert.py ~/data/RAW # Custom settings python3 scripts/convert.py ~/data/RAW ~/data/output/thumbnails --size 2048 --quality 95 # Read file list from stdin (pipe from find_by_date.py) python3 scripts/find_by_date.py --date today ~/data/RAW | \ python3 scripts/convert.py --from-stdin # With report output python3 scripts/convert.py ~/data/RAW --report /tmp/convert_report.json # Dry run python3 scripts/convert.py ~/data/RAW --dry-run ``` | Option | Description | Default | | -------------- | ---------------------------------- | --------------------- | | `input` | Photo file or directory | from config | | `output_dir` | Output directory | `{input}/thumbnails/` | | `--size` | Max thumbnail dimension (px) | 1200 | | `--quality` | JPEG quality (1-100) | 85 | | `--workers` | Parallel workers | auto (max 8) | | `--recursive` | Search subdirectories | off | | `--overwrite` | Overwrite existing files | off | | `--dry-run` | Preview only | off | | `--no-exif` | Skip EXIF copy | off | | `--report` | Output processing report JSON path | none | | `--from-stdin` | Read file paths from stdin (JSON) | off | ### 2. `find_by_date.py` — Find Photo Files by Date / Detect Timelapse Searches for RAW, JPG, and HEIC/HEIF files by EXIF shooting date. Outputs JSON path list to stdout. Also detects timelapse sequences by identifying runs of photos with regular shooting intervals. ```bash # Find by exact date (outputs JSON to stdout) python3 scripts/find_by_date.py --date 3月15日 python3 scripts/find_by_date.py --date 2026-03-15 # Date range python3 scripts/find_by_date.py --from 2026-03-10 --to 2026-03-15 # Save output to file python3 scripts/find_by_date.py --date 3月15日 --output ~/data/found_files.json # List all dates python3 scripts/find_by_date.py --list-dates # Timelapse: detect sequences with regular intervals, exclude casual shots python3 scripts/find_by_date.py ~/data/RAW --timelapse python3 scripts/find_by_date.py ~/data/RAW --timelapse --output ~/data/timelapse_found.json python3 scripts/find_by_date.py ~/data/RAW --timelapse --min-sequence 50 # Pipe to convert.py python3 scripts/find_by_date.py --date today ~/data/RAW | \ python3 scripts/convert.py --from-stdin ``` | Option | Description | Default | | ---------------------- | ---------------------------------------------- | ------- | | `--output`, `-o` | Save JSON output to file | stdout | | `--timelapse` | Detect timelapse sequences (regular intervals) | off | | `--min-sequence` | Minimum frames to qualify as timelapse | 30 | | `--interval-tolerance` | Interval deviation tolerance (0.5 = ±50%) | 0.5 | Supported date formats: `2026-03-15`, `03-15`, `3月15日`, `today`, `yesterday`, `3 days ago` ### 3. `layout_preview.py` — Layout Preview (Comparison / Grid) **Default: side-by-side comparison** (left=original, right=graded) When `--params` is provided and contains absolute paths, originals are resolved automatically without `--originals`. ```bash # Comparison mode with absolute paths in params (--originals not needed) python3 scripts/layout_preview.py ~/data/output/graded \ --params grading_params.json # Comparison mode with explicit originals directory python3 scripts/layout_preview.py ~/data/output/graded \ --originals ~/data/RAW --params grading_params.json # Grid mode (宫格) — only graded photos python3 scripts/layout_preview.py ~/data/output/graded --grid \ --params grading_params.json ``` | Option | Description | Default | | ------------- | ------------------------------- | ----------------------- | | `graded_dir` | Graded JPG directory (required) | — | | `--originals` | Original photos directory | auto-detect from params | | `--params` | grading_params.json path | none | | `--grid` | Use grid layout instead | off (comparison) | | `--cell-size` | Row height / grid cell px | 800 | | `--gap` | Gap between images px | 6 | | `--quality` | JPEG output quality | 92 | | `-o/--output` | Output path | `../layout_preview.jpg` | > **Note**: Without `--grid`, the script generates side-by-side BEFORE|AFTER comparisons. > Use `--grid` only when user explicitly requests 四宫格 or 九宫格. ## Agent Integration When the user asks to convert photo files or find photos by date: 1. **Check dependencies**: `bash scripts/setup_deps.sh` 2. **Determine input**: user path or config's `input_dir` 3. **Run the appropriate script** 4. **Report results** to the user

path: CHANGELOG.md
content: # Changelog — photo-toolkit

All notable changes to this skill will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This skill carries its own version per [strategy C in RELEASING.md](../RELEASING.md).

## [Unreleased]

## [1.0.0] - 2026-05-20

Initial public release on [ClawHub](https://clawhub.ai).

### Added

- Convert RAW / JPG / HEIC to JPG thumbnails (`convert.py`).
- Find photos by EXIF shooting date (`find_by_date.py`), supports timelapse sequence detection.
- Generate before/after layout previews (`layout_preview.py`).
- Deflicker timelapse frames (`deflicker.py`).
- Assemble JPG frames into MP4 (`assemble.py`).
- Supports Nikon (NEF), Canon (CR2/CR3), Sony (ARW), Fujifilm (RAF), Olympus (ORF), Panasonic (RW2), Pentax (PEF), Samsung (SRW), Leica (DNG), Hasselblad (3FR), Phase One (IIQ), Sigma (X3F), plus standard JPEG and Apple HEIC/HEIF.


path: VERSION
content: 1.0.0


path: config.example.toml
content: # photo-toolkit 配置

# 原始照片目录（RAW/JPG/HEIC 文件所在路径）
raw_dir = ""

# 缩略图输出目录
output_dir = ""

# 输出图片最大边长（像素），超过此尺寸会等比缩小
max_size = 1200

# 输出 JPEG 画质 (1-100)，缩略图建议 85
jpeg_quality = 85

# 是否递归搜索子目录中的照片（相机上传通常是多层目录，建议开启）
recursive = true

# 是否覆盖已存在的输出文件
overwrite = false

# 是否保留原始照片的 EXIF 元数据
preserve_exif = true

# 并行工作进程数，0 = 自动（取 CPU 核数，最多 8）
workers = 0

# LLM 视觉输入的总大小预算（MB）
# 小于 (预算 / 照片数) 的 JPG/HEIC 文件将直接软链接而非重新编码
# 设为 0 禁用此优化
thumbnail_budget_mb = 50


path: requirements.txt
content: # photo-toolkit dependencies
# System dependency: libraw (RedHat: dnf install LibRaw-devel / Debian: apt-get install libraw-dev)
rawpy>=0.19.0
pillow>=10.0.0
numpy>=1.24.0
pillow-heif>=0.13.0  # optional: HEIC/HEIF support (iPhone/iPad). NOTE: GPLv2 licensed


path: scripts/assemble.py
content: #!/usr/bin/env python3
"""
Assemble — Combine a sequence of JPG frames into an MP4 video via FFmpeg.

Takes a directory of sequentially-named JPG files and encodes them into
an H.264 MP4 video using FFmpeg. Frames are sorted by natural filename order.

Dependencies:
    System: ffmpeg (brew install ffmpeg / apt-get install ffmpeg)
    Python: (none beyond stdlib)

Usage:
    python assemble.py ~/Photos/graded --output timelapse.mp4
    python assemble.py ~/Photos/graded --output timelapse.mp4 --fps 30 --crf 15
    python assemble.py ~/Photos/graded --dry-run
"""

import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path


# ── Configuration ───────────────────────────────────────────────

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib

_SKILL_DIR = Path(__file__).resolve().parent.parent
_ROOT_DIR = _SKILL_DIR.parent
_DEFAULT_CONFIG_PATH = (
    _SKILL_DIR / "config.toml" if (_SKILL_DIR / "config.toml").exists() else _ROOT_DIR / "config.toml"
)


def load_config(config_path=None):
    """Load configuration from config.toml."""
    path = Path(config_path or _DEFAULT_CONFIG_PATH).expanduser().resolve()
    if not path.exists():
        return {}
    try:
        with open(path, "rb") as f:
            cfg = tomllib.load(f)
        if not isinstance(cfg, dict):
            return {}
        print(f"📄 Loaded config: {path}")
        return cfg
    except Exception:
        return {}


# ── Helpers ─────────────────────────────────────────────────────

JPG_EXTENSIONS = {".jpg", ".jpeg"}


def natural_sort_key(path):
    """Natural sort key: frame_0001 < frame_0002 < frame_0010."""
    parts = re.split(r"(\d+)", path.stem.lower())
    return [int(p) if p.isdigit() else p for p in parts]


def find_jpg_frames(input_dir):
    """Find all JPG files in directory, sorted by natural filename order."""
    input_dir = Path(input_dir)
    results = []
    for p in input_dir.iterdir():
        if p.is_file() and p.suffix.lower() in JPG_EXTENSIONS:
            results.append(p)
    results.sort(key=natural_sort_key)
    return results


# ── Main ────────────────────────────────────────────────────────


def main():
    parser = argparse.ArgumentParser(
        description="Assemble JPG frames into an MP4 video via FFmpeg",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
System dependency: ffmpeg
  macOS:  brew install ffmpeg
  Debian: apt-get install ffmpeg
  RHEL:   dnf install ffmpeg

Examples:
  %(prog)s ~/Photos/graded --output timelapse.mp4
  %(prog)s ~/Photos/graded -o timelapse.mp4 --fps 30 --crf 15
  %(prog)s ~/Photos/graded --dry-run
        """,
    )
    parser.add_argument("input", help="Directory containing JPG frames")
    parser.add_argument(
        "--output", "-o", type=str, default=None, help="Output video path (default: <input>/../timelapse.mp4)"
    )
    parser.add_argument("--fps", type=int, default=None, help="Video frame rate (default: 25)")
    parser.add_argument(
        "--crf", type=int, default=None, help="H.264 CRF quality: 0=lossless, 18=high, 23=default (default: 18)"
    )
    parser.add_argument("--config", type=str, default=None, help="Path to config.toml")
    parser.add_argument("--dry-run", action="store_true", help="Preview without encoding")

    args = parser.parse_args()
    cfg = load_config(args.config)

    fps = args.fps if args.fps is not None else cfg.get("fps", 25)
    crf = args.crf if args.crf is not None else cfg.get("crf", 18)

    input_dir = Path(args.input).expanduser().resolve()
    if not input_dir.exists():
        print(f"❌ Directory not found: {input_dir}", file=sys.stderr)
        sys.exit(1)

    # Output path
    if args.output:
        output_path = Path(args.output).expanduser().resolve()
    else:
        output_path = input_dir.parent / "timelapse.mp4"

    # ── Check FFmpeg ─────────────────────────────────────────────
    if not shutil.which("ffmpeg"):
        print("❌ FFmpeg not found.", file=sys.stderr)
        print("   Install with: brew install ffmpeg (macOS) / apt-get install ffmpeg", file=sys.stderr)
        sys.exit(1)

    # ── Find frames ──────────────────────────────────────────────
    frames = find_jpg_frames(input_dir)
    if not frames:
        print("❌ No JPG frames found in input directory.")
        sys.exit(1)

    duration = len(frames) / fps
    duration_str = f"{duration:.1f}s" if duration < 60 else f"{int(duration)//60}m {int(duration)%60}s"
    print(f"📷 Found {len(frames)} JPG frame(s)")
    print(f"   Duration: {duration_str} ({len(frames)} frames @ {fps}fps)")
    print(f"   CRF: {crf}")
    print(f"   Output: {output_path}")

    if args.dry_run:
        print(f"\n🔍 Dry run — no video created")
        print(f"   First frame: {frames[0].name}")
        print(f"   Last frame:  {frames[-1].name}")
        sys.exit(0)

    # ── Create sequential symlinks for FFmpeg ────────────────────
    # FFmpeg needs sequentially numbered files (frame_000001.jpg, frame_000002.jpg, ...)
    # Create temp dir with symlinks to ensure correct ordering
    tmp_dir = tempfile.mkdtemp(prefix="assemble_frames_")
    try:
        for i, frame in enumerate(frames):
            link_name = f"frame_{i:06d}.jpg"
            os.symlink(str(frame), os.path.join(tmp_dir, link_name))

        input_pattern = os.path.join(tmp_dir, "frame_%06d.jpg")
        output_path.parent.mkdir(parents=True, exist_ok=True)

        cmd = [
            "ffmpeg",
            "-y",
            "-framerate",
            str(fps),
            "-i",
            input_pattern,
            "-c:v",
            "libx264",
            "-crf",
            str(crf),
            "-pix_fmt",
            "yuv420p",
            "-movflags",
            "+faststart",
            str(output_path),
        ]

        print(f"\n🎬 Encoding video...")
        result = subprocess.run(cmd, capture_output=True, text=True)

        if result.returncode != 0:
            print(f"\n❌ FFmpeg failed (exit code {result.returncode}):", file=sys.stderr)
            stderr_lines = result.stderr.strip().split("\n")
            for line in stderr_lines[-10:]:
                print(f"   {line}", file=sys.stderr)
            sys.exit(1)

    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)

    # ── Summary ──────────────────────────────────────────────────
    if output_path.exists():
        size_mb = output_path.stat().st_size / (1024 * 1024)
        print(f"\n{'─' * 55}")
        print(f"✅ Video created: {output_path}")
        print(f"   Size: {size_mb:.1f} MB")
        print(f"   Duration: {duration_str} @ {fps}fps")
    else:
        print("❌ Video file was not created", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/convert.py
content: #!/usr/bin/env python3
"""
Photo to JPG Thumbnail Generator

Convert camera RAW files, JPG photos, and Apple HEIC/HEIF images
to JPG thumbnails with professional-quality output.
Uses rawpy (LibRaw) for RAW, Pillow for JPG, pillow-heif for HEIC/HEIF.

Supported Formats:
    Camera RAW:
        Nikon:    .nef, .nrw
        Canon:    .cr2, .cr3, .crw
        Sony:     .arw, .srf, .sr2
        Fujifilm: .raf
        Olympus:  .orf
        Panasonic:.rw2
        Pentax:   .pef
        Samsung:  .srw
        Leica:    .rwl, .dng
        Adobe:    .dng
        Hasselblad: .3fr, .fff
        Phase One: .iiq
        Sigma:    .x3f
    Standard Image:
        JPEG:     .jpg, .jpeg
    Apple:
        HEIC/HEIF: .heic, .heif (iPhone/iPad 默认格式)

Dependencies:
    System: libraw (RedHat: dnf install LibRaw-devel / Debian: apt-get install libraw-dev)
    Python: pip install rawpy pillow numpy pillow-heif

    Check & install: bash scripts/setup_deps.sh

Configuration:
    Default paths and options from config.toml (next to scripts/ dir).
    Command-line arguments always override config values.

Usage:
    python convert.py /path/to/raw/files /path/to/output
    python convert.py /path/to/photo.NEF /path/to/output
    python convert.py --config /path/to/config.toml
"""

import argparse
import json
import os
import sys
import time
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor, as_completed


# ── Supported extensions ───────────────────────────────────────
RAW_EXTENSIONS = {
    # Nikon
    ".nef",
    ".nrw",
    # Canon
    ".cr2",
    ".cr3",
    ".crw",
    # Sony
    ".arw",
    ".srf",
    ".sr2",
    # Fujifilm
    ".raf",
    # Olympus / OM System
    ".orf",
    # Panasonic
    ".rw2",
    # Pentax
    ".pef",
    # Samsung
    ".srw",
    # Leica
    ".rwl",
    # Adobe DNG (universal)
    ".dng",
    # Hasselblad
    ".3fr",
    ".fff",
    # Phase One
    ".iiq",
    # Sigma
    ".x3f",
}

JPG_EXTENSIONS = {".jpg", ".jpeg"}

HEIC_EXTENSIONS = {".heic", ".heif"}

# All supported input formats
SUPPORTED_EXTENSIONS = RAW_EXTENSIONS | JPG_EXTENSIONS | HEIC_EXTENSIONS

# Check HEIC support availability
_HEIC_AVAILABLE = False
try:
    from pillow_heif import register_heif_opener

    register_heif_opener()
    _HEIC_AVAILABLE = True
except ImportError:
    pass


# ── Configuration ───────────────────────────────────────────────

try:
    import tomllib  # Python 3.11+
except ModuleNotFoundError:
    import tomli as tomllib  # pip install tomli  (Python 3.8-3.10)

_SKILL_DIR = Path(__file__).resolve().parent.parent
_ROOT_DIR = _SKILL_DIR.parent
_DEFAULT_CONFIG_PATH = (
    _SKILL_DIR / "config.toml" if (_SKILL_DIR / "config.toml").exists() else _ROOT_DIR / "config.toml"
)


def load_config(config_path=None):
    """Load configuration from config.toml."""
    path = Path(config_path or _DEFAULT_CONFIG_PATH).expanduser().resolve()
    if not path.exists():
        return {}
    try:
        with open(path, "rb") as f:
            cfg = tomllib.load(f)
        if not isinstance(cfg, dict):
            print(f"⚠️  Config is not a valid mapping, ignoring: {path}", file=sys.stderr)
            return {}
        print(f"📄 Loaded config: {path}")
        return cfg
    except Exception as e:
        print(f"⚠️  Failed to read config ({path}): {e}", file=sys.stderr)
        return {}


# ── Dependency Check ────────────────────────────────────────────


def check_dependencies():
    """Check if required packages are installed and report missing ones."""
    missing = []
    for pkg, pip_name in [("rawpy", "rawpy"), ("PIL", "pillow"), ("numpy", "numpy")]:
        try:
            __import__(pkg)
        except ImportError:
            missing.append(pip_name)
    if missing:
        print("❌ Missing required dependencies:", file=sys.stderr)
        for pkg in missing:
            print(f"  - {pkg}", file=sys.stderr)
        print(f"\nInstall with:\n  pip3 install {' '.join(missing)}", file=sys.stderr)
        print(f"\nOr run:\n  bash {_SKILL_DIR}/scripts/setup_deps.sh", file=sys.stderr)
        sys.exit(1)


check_dependencies()

import rawpy
from PIL import Image
import numpy as np


def get_cpu_count():
    """Get a reasonable default worker count (cpu_count * 2, max 16)."""
    try:
        count = os.cpu_count() or 4
        return min(count * 2, 16)
    except Exception:
        return 4


def process_file(raw_path, output_dir, size, quality, overwrite=False, preserve_exif=True, per_image_budget_bytes=0):
    """
    Process a single photo file (RAW/JPG/HEIC) to JPG thumbnail.

    If per_image_budget_bytes > 0 and the source is JPG/HEIC (not RAW),
    files smaller than the budget are symlinked instead of re-encoded.
    This avoids pointless re-compression of already-small images.

    Returns:
        Tuple of (filename, success, message, elapsed_seconds)
    """
    start = time.monotonic()
    raw_name = raw_path.name
    ext_lower = raw_path.suffix.lower()

    try:
        jpg_name = raw_path.stem + ".jpg"
        jpg_path = output_dir / jpg_name

        if jpg_path.exists() and not overwrite:
            elapsed = time.monotonic() - start
            return (raw_name, True, f"Skipped (exists): {jpg_name}", elapsed)

        # ── Smart skip: symlink small JPG/HEIC originals ─────────
        is_non_raw = ext_lower in (JPG_EXTENSIONS | HEIC_EXTENSIONS)
        if is_non_raw and per_image_budget_bytes > 0:
            file_size = raw_path.stat().st_size
            if file_size <= per_image_budget_bytes:
                # Original is small enough — symlink instead of re-encoding
                try:
                    os.symlink(str(raw_path.resolve()), str(jpg_path))
                except OSError:
                    # Fallback: copy if symlink fails (e.g. cross-device)
                    import shutil

                    shutil.copy2(str(raw_path), str(jpg_path))
                elapsed = time.monotonic() - start
                file_size_kb = file_size / 1024
                return (
                    raw_name,
                    True,
                    f"⚡ {jpg_name} (linked, {file_size_kb:.0f}KB ≤ budget {per_image_budget_bytes/1024:.0f}KB)",
                    elapsed,
                )

        # ── Determine processing path ────────────────────────────
        if ext_lower in RAW_EXTENSIONS:
            # RAW Processing via rawpy
            with rawpy.imread(str(raw_path)) as raw:
                rgb = raw.postprocess(
                    use_camera_wb=True,
                    use_auto_wb=False,
                    no_auto_bright=False,
                    output_bps=8,
                    half_size=True if size <= 2048 else False,
                    demosaic_algorithm=rawpy.DemosaicAlgorithm.AHD,
                    output_color=rawpy.ColorSpace.sRGB,
                )
            image = Image.fromarray(rgb)

        elif ext_lower in HEIC_EXTENSIONS:
            # HEIC/HEIF via pillow-heif
            if not _HEIC_AVAILABLE:
                elapsed = time.monotonic() - start
                return (raw_name, False, f"✗ {raw_name}: pillow-heif not installed (pip install pillow-heif)", elapsed)
            image = Image.open(str(raw_path))

        else:
            # JPG/JPEG via Pillow directly
            image = Image.open(str(raw_path))

        # ── Auto-rotate via EXIF ─────────────────────────────────
        try:
            from PIL import ImageOps

            image = ImageOps.exif_transpose(image)
        except Exception:
            pass

        # ── Resize ──────────────────────────────────────────────
        image.thumbnail((size, size), Image.Resampling.LANCZOS)

        # ── EXIF Handling ───────────────────────────────────────
        exif_bytes = None
        if preserve_exif:
            try:
                with open(str(raw_path), "rb") as f:
                    header = f.read(65536)
                exif_start = header.find(b"\xff\xe1")
                if exif_start != -1:
                    exif_length = int.from_bytes(header[exif_start + 2 : exif_start + 4], "big")
                    exif_bytes = header[exif_start : exif_start + 2 + exif_length]
            except Exception:
                exif_bytes = None

        # ── Save JPEG ──────────────────────────────────────────
        save_kwargs = {
            "format": "JPEG",
            "quality": quality,
            "optimize": True,
            "subsampling": 0 if quality >= 90 else 2,
        }
        if exif_bytes:
            save_kwargs["exif"] = exif_bytes

        image.save(jpg_path, **save_kwargs)

        elapsed = time.monotonic() - start
        file_size_kb = jpg_path.stat().st_size / 1024
        return (raw_name, True, f"✓ {jpg_name} ({image.width}×{image.height}, {file_size_kb:.0f}KB)", elapsed)

    except Exception as e:
        elapsed = time.monotonic() - start
        return (raw_name, False, f"✗ {raw_name}: {e}", elapsed)


def find_supported_files(input_path, recursive=False):
    """
    Find all supported photo files (RAW/JPG/HEIC) in directory.
    Case-insensitive matching for all supported extensions.
    Skips special directories (thumbnails, graded, sessions, .ds_store).
    """
    SKIP_DIRS = {"thumbnails", "graded", "sessions", ".ds_store"}

    if input_path.is_file():
        if input_path.suffix.lower() in SUPPORTED_EXTENSIONS:
            return [input_path]
        return []

    results = []
    if recursive:
        for p in sorted(input_path.rglob("*")):
            # Skip special directories
            if any(part in SKIP_DIRS for part in p.parts):
                continue
            if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS:
                results.append(p)
    else:
        for p in sorted(input_path.iterdir()):
            if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS:
                results.append(p)
    return results


def format_time(seconds):
    """Format elapsed seconds to human-readable string."""
    if seconds < 60:
        return f"{seconds:.1f}s"
    minutes = int(seconds) // 60
    secs = seconds - minutes * 60
    return f"{minutes}m {secs:.0f}s"


def main():
    parser = argparse.ArgumentParser(
        description="Convert photos to JPG thumbnails (supports RAW/JPG/HEIC/HEIF)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Supported formats:
  Camera RAW: Nikon (.nef .nrw), Canon (.cr2 .cr3 .crw), Sony (.arw .srf .sr2),
    Fujifilm (.raf), Olympus (.orf), Panasonic (.rw2), Pentax (.pef),
    Samsung (.srw), Leica (.rwl .dng), Adobe (.dng), Hasselblad (.3fr .fff),
    Phase One (.iiq), Sigma (.x3f)
  Standard: JPEG (.jpg .jpeg)
  Apple: HEIC (.heic .heif) — requires: pip install pillow-heif

Examples:
  %(prog)s ~/Photos/raw ~/Photos/thumbs
  %(prog)s ~/Photos/raw ~/Photos/thumbs --size 800 --quality 90
  %(prog)s ~/Photos/DSC_0001.NEF ~/Photos/thumbs
  %(prog)s ~/Photos/IMG_0001.HEIC ~/Photos/thumbs
  %(prog)s ~/Photos/raw ~/Photos/thumbs -r --overwrite
        """,
    )
    parser.add_argument("input", nargs="?", default=None, help="Photo file or directory (default: from config.toml)")
    parser.add_argument(
        "output_dir", nargs="?", default=None, help="Directory for output JPG files (default: {input}/thumbnails/)"
    )
    parser.add_argument("--config", type=str, default=None, help="Path to config.toml")
    parser.add_argument("--size", type=int, default=None, help="Maximum thumbnail dimension in pixels (default: 1200)")
    parser.add_argument("--quality", type=int, default=None, help="JPEG quality 1-100 (default: 85)")
    parser.add_argument("--workers", type=int, default=None, help="Number of parallel workers (default: auto, max 8)")
    parser.add_argument("--recursive", "-r", action="store_true", default=None, help="Search subdirectories")
    parser.add_argument("--overwrite", action="store_true", default=None, help="Overwrite existing JPG files")
    parser.add_argument("--dry-run", action="store_true", help="Preview without converting")
    parser.add_argument("--no-exif", action="store_true", default=None, help="Do not copy EXIF metadata")
    parser.add_argument(
        "--max-total-mb",
        type=float,
        default=None,
        help="Total image budget in MB for LLM vision input. "
        "JPG/HEIC files smaller than (budget / n_files) are symlinked "
        "instead of re-encoded. Set 0 to disable. (default: 50)",
    )
    parser.add_argument("--report", type=str, default=None, help="处理报告输出 JSON 路径")
    parser.add_argument("--from-stdin", action="store_true", help="从 stdin 读取文件路径列表（JSON 格式）")

    args = parser.parse_args()

    # ── Load config and merge ───────────────────────────────────
    cfg = load_config(args.config)

    input_raw = args.input or cfg.get("raw_dir") or cfg.get("input_dir")
    if not input_raw and not args.from_stdin:
        parser.error("input path is required. Provide it as an argument or set 'raw_dir' in config.toml")

    output_raw = args.output_dir or cfg.get("output_dir")
    if not output_raw:
        if input_raw:
            output_raw = str(Path(input_raw).expanduser().resolve() / "thumbnails")
        elif args.from_stdin:
            parser.error("--output is required when using --from-stdin without an input directory")
        else:
            parser.error("output path is required. Provide it as an argument or set 'output_dir' in config.toml")

    size = args.size if args.size is not None else cfg.get("max_size", 1200)
    quality = args.quality if args.quality is not None else cfg.get("jpeg_quality", 85)
    workers = args.workers if args.workers is not None else cfg.get("workers") or get_cpu_count()
    recursive = args.recursive if args.recursive is not None else cfg.get("recursive", False)
    overwrite = args.overwrite if args.overwrite is not None else cfg.get("overwrite", False)
    preserve_exif = not args.no_exif if args.no_exif is not None else cfg.get("preserve_exif", True)
    max_total_mb = args.max_total_mb if args.max_total_mb is not None else cfg.get("thumbnail_budget_mb", 50)

    if not 1 <= quality <= 100:
        print("Error: --quality must be between 1 and 100", file=sys.stderr)
        sys.exit(1)
    if size < 100:
        print("Error: --size must be at least 100", file=sys.stderr)
        sys.exit(1)

    input_path = Path(input_raw).expanduser().resolve() if input_raw else None
    output_path = Path(output_raw).expanduser().resolve()

    if input_path and not input_path.exists():
        print(f"Error: Input does not exist: {input_path}", file=sys.stderr)
        sys.exit(1)

    # ── Find photo files ─────────────────────────────────────────
    if args.from_stdin:
        stdin_data = json.load(sys.stdin)
        file_list = stdin_data.get("files", [])
        photo_files = [Path(f) for f in file_list if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS]
    else:
        photo_files = find_supported_files(input_path, recursive)

    if not photo_files:
        print("No supported photo files found.")
        # Show supported extensions
        ext_list = sorted(SUPPORTED_EXTENSIONS)
        print(f"Supported extensions: {', '.join(ext_list)}")
        if not _HEIC_AVAILABLE:
            print("  Note: HEIC/HEIF support requires: pip install pillow-heif")
        sys.exit(0)

    # Summarize formats found
    ext_counts = {}
    for f in photo_files:
        ext = f.suffix.upper()
        ext_counts[ext] = ext_counts.get(ext, 0) + 1
    ext_summary = ", ".join(f"{ext}: {cnt}" for ext, cnt in sorted(ext_counts.items()))
    print(f"📷 Found {len(photo_files)} photo file(s) ({ext_summary})")

    # ── Dry run ─────────────────────────────────────────────────
    if args.dry_run:
        print(f"\n🔍 Dry run — files that would be converted:")
        for photo in photo_files:
            jpg_name = photo.stem + ".jpg"
            jpg_path = output_path / jpg_name
            if jpg_path.exists() and not overwrite:
                print(f"  ⏭  {photo.name} → {jpg_name} (exists, skip)")
            else:
                print(f"  📸 {photo.name} → {jpg_name}")
        sys.exit(0)

    output_path.mkdir(parents=True, exist_ok=True)

    # ── Process files ───────────────────────────────────────────
    # Calculate per-image budget for smart-skip of small JPG/HEIC
    n_files = len(photo_files)
    if max_total_mb > 0 and n_files > 0:
        per_image_budget_bytes = int(max_total_mb * 1024 * 1024 / n_files)
    else:
        per_image_budget_bytes = 0

    print(f"\n⚙️  Converting: size={size}px, quality={quality}, workers={workers}")
    if per_image_budget_bytes > 0:
        print(
            f"   Budget: {max_total_mb:.0f}MB total → {per_image_budget_bytes/1024:.0f}KB/image (small JPG/HEIC will be linked)"
        )
    print(f"   Input:  {input_path or 'stdin'}")
    print(f"   Output: {output_path}\n")

    total_start = time.monotonic()
    success_count = 0
    skip_count = 0
    linked_count = 0
    error_count = 0
    total = len(photo_files)

    with ProcessPoolExecutor(max_workers=workers) as executor:
        futures = {
            executor.submit(
                process_file, photo, output_path, size, quality, overwrite, preserve_exif, per_image_budget_bytes
            ): photo
            for photo in photo_files
        }

        done = 0
        for future in as_completed(futures):
            done += 1
            raw_name, success, message, elapsed = future.result()
            progress = f"[{done}/{total}]"
            print(f"  {progress} {message} ({format_time(elapsed)})")

            if success:
                if "Skipped" in message:
                    skip_count += 1
                elif "linked" in message:
                    linked_count += 1
                else:
                    success_count += 1
            else:
                error_count += 1

    # ── Summary ─────────────────────────────────────────────────
    total_elapsed = time.monotonic() - total_start
    print(f"\n{'─' * 55}")
    print(f"✅ Done in {format_time(total_elapsed)}")
    parts = [f"Converted: {success_count}"]
    if linked_count > 0:
        parts.append(f"Linked: {linked_count}")
    parts.extend([f"Skipped: {skip_count}", f"Errors: {error_count}"])
    print(f"   {'  |  '.join(parts)}")
    print(f"   Output: {output_path}")

    if error_count > 0:
        sys.exit(1)

    # ── Write report ────────────────────────────────────────────
    if args.report:
        report = {
            "input": str(input_path) if input_path else "stdin",
            "output": str(output_path),
            "total": len(photo_files),
            "converted": success_count,
            "linked": linked_count,
            "skipped": skip_count,
            "errors": error_count,
        }
        report_path = Path(args.report).expanduser().resolve()
        report_path.parent.mkdir(parents=True, exist_ok=True)
        report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"\n📄 报告已保存: {report_path}")


if __name__ == "__main__":
    main()


path: scripts/deflicker.py
content: #!/usr/bin/env python3
"""
Deflicker — Smooth luminance fluctuations in a sequence of JPG frames.

Timelapse sequences often exhibit frame-to-frame brightness flicker due to
aperture/shutter micro-variations. This script applies a sliding-window
luminance smoothing to produce flicker-free results.

Algorithm:
  1. Compute mean luminance for each frame (sorted by filename)
  2. Apply a sliding-window average to create a smooth luminance curve
  3. Adjust each frame's brightness by the ratio: smoothed / actual
  4. Clamp correction ratios to 0.5–2.0× to prevent extreme adjustments

Dependencies:
    Python: pip install pillow numpy

Usage:
    python deflicker.py ~/Photos/graded
    python deflicker.py ~/Photos/graded --window 15
    python deflicker.py ~/Photos/graded --dry-run
"""

import argparse
import os
import re
import sys
import time
from pathlib import Path

try:
    from PIL import Image
    import numpy as np
except ImportError:
    print("❌ Missing dependencies: pillow, numpy", file=sys.stderr)
    print("   Install with: pip3 install pillow numpy", file=sys.stderr)
    sys.exit(1)


# ── Configuration ───────────────────────────────────────────────

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib

_SKILL_DIR = Path(__file__).resolve().parent.parent
_ROOT_DIR = _SKILL_DIR.parent
_DEFAULT_CONFIG_PATH = (
    _SKILL_DIR / "config.toml" if (_SKILL_DIR / "config.toml").exists() else _ROOT_DIR / "config.toml"
)


def load_config(config_path=None):
    """Load configuration from config.toml."""
    path = Path(config_path or _DEFAULT_CONFIG_PATH).expanduser().resolve()
    if not path.exists():
        return {}
    try:
        with open(path, "rb") as f:
            cfg = tomllib.load(f)
        if not isinstance(cfg, dict):
            return {}
        print(f"📄 Loaded config: {path}")
        return cfg
    except Exception:
        return {}


# ── Helpers ─────────────────────────────────────────────────────

JPG_EXTENSIONS = {".jpg", ".jpeg"}


def natural_sort_key(path):
    """Natural sort key: frame_0001 < frame_0002 < frame_0010."""
    parts = re.split(r"(\d+)", path.stem.lower())
    return [int(p) if p.isdigit() else p for p in parts]


def find_jpg_frames(input_dir):
    """Find all JPG files in directory, sorted by natural filename order."""
    input_dir = Path(input_dir)
    results = []
    for p in input_dir.iterdir():
        if p.is_file() and p.suffix.lower() in JPG_EXTENSIONS:
            results.append(p)
    results.sort(key=natural_sort_key)
    return results


def compute_mean_luminance(img_path):
    """Compute mean luminance of a JPG image."""
    img = Image.open(img_path).convert("RGB")
    arr = np.array(img).astype(np.float64) / 255.0
    luminance = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2]
    return float(np.mean(luminance))


def format_time(seconds):
    if seconds < 60:
        return f"{seconds:.1f}s"
    minutes = int(seconds) // 60
    secs = seconds - minutes * 60
    return f"{minutes}m {secs:.0f}s"


# ── Main ────────────────────────────────────────────────────────


def main():
    parser = argparse.ArgumentParser(
        description="Smooth luminance fluctuations in a sequence of JPG frames (deflicker)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Algorithm:
  1. Compute mean luminance for each frame
  2. Sliding-window average → smooth luminance curve
  3. Adjust each frame: brightness *= smoothed / actual
  4. Clamp ratios to 0.5–2.0× for safety

Examples:
  %(prog)s ~/Photos/graded
  %(prog)s ~/Photos/graded --window 15
  %(prog)s ~/Photos/graded --dry-run
        """,
    )
    parser.add_argument("input", help="Directory containing JPG frames")
    parser.add_argument("--window", type=int, default=None, help="Sliding window size, odd number (default: 11)")
    parser.add_argument("--quality", type=int, default=None, help="Output JPEG quality (default: 95)")
    parser.add_argument("--config", type=str, default=None, help="Path to config.toml")
    parser.add_argument("--dry-run", action="store_true", help="Analyze without modifying files")
    parser.add_argument("--backup", action="store_true", help="Create backup copies before modifying (saved as .bak)")

    args = parser.parse_args()
    cfg = load_config(args.config)

    window = args.window if args.window is not None else cfg.get("deflicker_window", 11)
    quality = args.quality if args.quality is not None else cfg.get("jpeg_quality", 95)

    # Ensure odd window
    if window % 2 == 0:
        window += 1

    input_dir = Path(args.input).expanduser().resolve()
    if not input_dir.exists():
        print(f"❌ Directory not found: {input_dir}", file=sys.stderr)
        sys.exit(1)

    # ── Find frames ──────────────────────────────────────────────
    frames = find_jpg_frames(input_dir)
    if len(frames) < 3:
        print(f"❌ Need at least 3 JPG frames for deflicker, found {len(frames)}")
        sys.exit(1)

    print(f"📷 Found {len(frames)} JPG frame(s)")
    print(f"   Window: {window}")

    # ── Step 1: Compute luminances ───────────────────────────────
    print(f"\n━━━ Step 1/2: Analyzing luminance ━━━\n")
    start = time.monotonic()
    luminances = []
    for i, f in enumerate(frames):
        lum = compute_mean_luminance(f)
        luminances.append(lum)
        if (i + 1) % 50 == 0 or i == len(frames) - 1:
            print(f"  [{i+1}/{len(frames)}] analyzed")

    lum_array = np.array(luminances)
    analyze_elapsed = time.monotonic() - start
    print(
        f"\n  Luminance range: {lum_array.min():.4f} → {lum_array.max():.4f} (Δ{lum_array.max() - lum_array.min():.4f})"
    )
    print(f"  Analyzed in {format_time(analyze_elapsed)}")

    # ── Compute smoothed luminance ───────────────────────────────
    n = len(luminances)
    half_w = window // 2
    smoothed = np.zeros(n)
    for i in range(n):
        lo = max(0, i - half_w)
        hi = min(n, i + half_w + 1)
        smoothed[i] = np.mean(lum_array[lo:hi])

    ratios = np.where(lum_array > 1e-6, smoothed / lum_array, 1.0)
    ratios = np.clip(ratios, 0.5, 2.0)

    need_adjust = np.sum(np.abs(ratios - 1.0) >= 0.005)
    max_correction = np.max(np.abs(ratios - 1.0))
    print(f"  Frames needing adjustment: {need_adjust}/{n}")
    print(f"  Max correction: {max_correction:.4f} ({max_correction*100:.1f}%)")

    if args.dry_run:
        print(f"\n🔍 Dry run — no files modified")
        # Show top corrections
        corrections = [(i, ratios[i]) for i in range(n) if abs(ratios[i] - 1.0) >= 0.005]
        corrections.sort(key=lambda x: abs(x[1] - 1.0), reverse=True)
        if corrections:
            print(f"   Top corrections:")
            for idx, ratio in corrections[:10]:
                direction = "↑" if ratio > 1.0 else "↓"
                print(f"     {frames[idx].name}: {direction} {abs(ratio-1.0)*100:.1f}%")
        sys.exit(0)

    # ── Step 2: Apply corrections ────────────────────────────────
    print(f"\n━━━ Step 2/2: Applying corrections ━━━\n")
    adjust_start = time.monotonic()
    adjusted = 0

    # Create backups if requested
    if args.backup:
        print("  Creating backups...")
        import shutil

        for i, frame_path in enumerate(frames):
            if abs(ratios[i] - 1.0) >= 0.005:
                backup_path = frame_path.with_suffix(".jpg.bak")
                if not backup_path.exists():
                    shutil.copy2(frame_path, backup_path)
        print(f"  ✓ Backups created (.jpg.bak)\n")

    for i in range(n):
        if abs(ratios[i] - 1.0) < 0.005:
            continue

        frame_path = frames[i]
        img = Image.open(frame_path)
        arr = np.array(img).astype(np.float64)
        arr *= ratios[i]
        arr = np.clip(arr, 0, 255).astype(np.uint8)
        Image.fromarray(arr).save(frame_path, format="JPEG", quality=quality, optimize=True, subsampling=0)
        adjusted += 1

        if adjusted % 50 == 0:
            print(f"  [{adjusted}] frames adjusted...")

    adjust_elapsed = time.monotonic() - adjust_start
    total_elapsed = time.monotonic() - start

    print(f"\n{'─' * 55}")
    print(f"✅ Done in {format_time(total_elapsed)}")
    print(f"   Adjusted: {adjusted}  |  Unchanged: {n - adjusted}")
    print(f"   Directory: {input_dir}")


if __name__ == "__main__":
    main()


path: scripts/file_matcher.py
content: #!/usr/bin/env python3
"""
Shared file matching utilities for cross-format photo file matching.

Used by grade.py and layout_preview.py to find original files
regardless of file extension differences.
"""

from pathlib import Path

# Supported extensions (must match grade.py and layout_preview.py)
RAW_EXTENSIONS = {
    ".nef",
    ".nrw",  # Nikon
    ".cr2",
    ".cr3",
    ".crw",  # Canon
    ".arw",
    ".srf",
    ".sr2",  # Sony
    ".raf",  # Fujifilm
    ".orf",  # Olympus / OM System
    ".rw2",  # Panasonic
    ".pef",  # Pentax
    ".srw",  # Samsung
    ".rwl",  # Leica
    ".dng",  # Adobe DNG
    ".3fr",
    ".fff",  # Hasselblad
    ".iiq",  # Phase One
    ".x3f",  # Sigma
}

JPG_EXTENSIONS = {".jpg", ".jpeg"}

HEIC_EXTENSIONS = {".heic", ".heif"}

SUPPORTED_EXTENSIONS = RAW_EXTENSIONS | JPG_EXTENSIONS | HEIC_EXTENSIONS


def find_raw_file(file_ref, raw_root=None, supported_extensions=None):
    """
    Find a RAW file by absolute path or by stem name under raw_root.

    Supports:
        - Absolute path that exists → use directly
        - Absolute path that doesn't exist → try different extensions
        - Relative filename → search under raw_root by stem

    Args:
        file_ref: Absolute path string, or filename/stem to search
        raw_root: Root directory to search in (used when file_ref is not absolute)
        supported_extensions: Set of supported extensions

    Returns:
        Path object if found, None otherwise
    """
    if supported_extensions is None:
        supported_extensions = SUPPORTED_EXTENSIONS

    file_ref_path = Path(file_ref)

    # If it's already an absolute path that exists, use it directly
    if file_ref_path.is_absolute() and file_ref_path.exists():
        return file_ref_path

    # If absolute but doesn't exist, try with different extensions
    if file_ref_path.is_absolute():
        return find_file_by_stem(file_ref_path.parent, file_ref_path.stem, supported_extensions)

    # Relative path: search under raw_root
    if raw_root:
        raw_root = Path(raw_root)
        # Try as relative path from raw_root
        candidate = raw_root / file_ref
        if candidate.exists():
            return candidate
        # Try stem matching (also searches subdirs if recursive needed)
        result = find_file_by_stem(raw_root, file_ref_path.stem, supported_extensions)
        if result:
            return result
        # Try recursive search in subdirectories
        for p in sorted(raw_root.rglob(f"{file_ref_path.stem}*")):
            if p.is_file() and p.suffix.lower() in supported_extensions:
                return p

    return None


def find_file_by_stem(directory, stem, supported_extensions=None):
    """
    Find a file by its stem name, trying multiple extensions and case variations.

    Args:
        directory: Directory to search in
        stem: File stem (name without extension)
        supported_extensions: Set of supported extensions (default: SUPPORTED_EXTENSIONS)

    Returns:
        Path object if found, None otherwise
    """
    if supported_extensions is None:
        supported_extensions = SUPPORTED_EXTENSIONS

    directory = Path(directory)
    if not directory.exists():
        return None

    # Try exact match first
    for ext in supported_extensions:
        for name in [f"{stem}{ext}", f"{stem.upper()}{ext}", f"{stem.lower()}{ext}", f"{stem}{ext.upper()}"]:
            candidate = directory / name
            if candidate.exists():
                return candidate

    # Try glob pattern matching
    matches = []
    for ext in supported_extensions:
        matches.extend(directory.glob(f"{stem}*{ext}"))
        matches.extend(directory.glob(f"{stem.upper()}*{ext}"))

    file_matches = [m for m in matches if m.suffix.lower() in supported_extensions]
    if file_matches:
        return file_matches[0]

    return None


def find_original_for_graded(graded_path, originals_dir, params_json=None, supported_extensions=None):
    """
    Find the corresponding original file for a graded image.

    Uses grading_params.json 'file' field for exact mapping, falls back to stem matching.

    Args:
        graded_path: Path to the graded image
        originals_dir: Directory containing original files
        params_json: Optional path to grading_params.json for exact mapping
        supported_extensions: Set of supported extensions (default: SUPPORTED_EXTENSIONS)

    Returns:
        Path object if found, None otherwise
    """
    if supported_extensions is None:
        supported_extensions = SUPPORTED_EXTENSIONS

    originals_dir = Path(originals_dir)
    if not originals_dir.exists():
        return None

    # Build index of originals
    orig_index = {}
    for p in originals_dir.iterdir():
        if p.is_file() and p.suffix.lower() in supported_extensions:
            orig_index[p.stem.lower()] = p

    # Try matching by stripping style suffix from graded filename
    # "DSC_0001_暖春丝滑.jpg" → try "DSC_0001"
    graded_stem = graded_path.stem.lower()

    # Try exact match first
    if graded_stem in orig_index:
        return orig_index[graded_stem]

    # Try progressively removing trailing "_xxx" suffixes
    parts = graded_stem.rsplit("_", 1)
    while len(parts) > 1:
        candidate = parts[0]
        if candidate in orig_index:
            return orig_index[candidate]
        parts = candidate.rsplit("_", 1)

    return None


path: scripts/find_by_date.py
content: #!/usr/bin/env python3
"""
Photo File Finder — Find photo files by shooting date.

Read EXIF DateTimeOriginal from camera RAW/JPG/HEIC files and filter by date criteria.
Supports all major camera RAW formats (NEF, CR2, CR3, ARW, RAF, ORF, DNG, etc.)

Dependencies:
    None (pure Python EXIF parsing via struct)

    Check & install: bash scripts/setup_deps.sh

Usage:
    python find_by_date.py --date 2026-03-15
    python find_by_date.py --date 03-15
    python find_by_date.py --from 2026-03-10 --to 2026-03-15
    python find_by_date.py --date 2026-03-15 --output result.json
    python find_by_date.py --list-dates

    # Timelapse detection: find sequences with regular shooting intervals
    python find_by_date.py ~/Photos/RAW --timelapse
    python find_by_date.py ~/Photos/RAW --from 06:00 --to 08:00 --timelapse
    python find_by_date.py ~/Photos/RAW --timelapse --output timelapse.json
"""

import argparse
import json
import os
import re
import struct
import sys
import time
from datetime import datetime, date, timedelta
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed


# ── Supported photo extensions ─────────────────────────────────
RAW_EXTENSIONS = {
    ".nef",
    ".nrw",  # Nikon
    ".cr2",
    ".cr3",
    ".crw",  # Canon
    ".arw",
    ".srf",
    ".sr2",  # Sony
    ".raf",  # Fujifilm
    ".orf",  # Olympus / OM System
    ".rw2",  # Panasonic
    ".pef",  # Pentax
    ".srw",  # Samsung
    ".rwl",  # Leica
    ".dng",  # Adobe DNG
    ".3fr",
    ".fff",  # Hasselblad
    ".iiq",  # Phase One
    ".x3f",  # Sigma
}

JPG_EXTENSIONS = {".jpg", ".jpeg"}

HEIC_EXTENSIONS = {".heic", ".heif"}

# All supported input formats
SUPPORTED_EXTENSIONS = RAW_EXTENSIONS | JPG_EXTENSIONS | HEIC_EXTENSIONS


# ── Configuration ───────────────────────────────────────────────

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib

_SKILL_DIR = Path(__file__).resolve().parent.parent
_ROOT_DIR = _SKILL_DIR.parent
_DEFAULT_CONFIG_PATH = (
    _SKILL_DIR / "config.toml" if (_SKILL_DIR / "config.toml").exists() else _ROOT_DIR / "config.toml"
)


def load_config(config_path=None):
    """Load configuration from config.toml."""
    path = Path(config_path or _DEFAULT_CONFIG_PATH).expanduser().resolve()
    if not path.exists():
        return {}
    try:
        with open(path, "rb") as f:
            cfg = tomllib.load(f)
        return cfg if isinstance(cfg, dict) else {}
    except Exception:
        return {}


# ── EXIF Date Reader ────────────────────────────────────────────


def _read_uint16(data, offset, big_endian):
    fmt = ">H" if big_endian else "<H"
    return struct.unpack_from(fmt, data, offset)[0]


def _read_uint32(data, offset, big_endian):
    fmt = ">I" if big_endian else "<I"
    return struct.unpack_from(fmt, data, offset)[0]


def read_exif_date(raw_path):
    """
    Read DateTimeOriginal from a camera RAW file's EXIF data.
    Works with NEF, CR2, ARW, DNG, and other TIFF-based RAW formats.
    """
    try:
        with open(raw_path, "rb") as f:
            header = f.read(256 * 1024)

        exif_data = None
        exif_offset = 0

        # Try TIFF header directly (most RAW files: NEF, CR2, ARW, DNG, ORF, PEF, etc.)
        if header[:2] in (b"MM", b"II"):
            exif_data = header
            exif_offset = 0
        else:
            # Look for EXIF APP1 marker (0xFFE1) — JPEG-wrapped formats
            idx = header.find(b"\xff\xe1")
            if idx != -1:
                length = struct.unpack(">H", header[idx + 2 : idx + 4])[0]
                if header[idx + 4 : idx + 10] == b"Exif\x00\x00":
                    exif_data = header[idx + 10 : idx + 2 + length]
                    exif_offset = 0

        # For Fujifilm RAF: look for TIFF header after RAF magic
        if exif_data is None and header[:15] == b"FUJIFILMCCD-RAW":
            # RAF files have TIFF-like EXIF embedded at an offset
            for search_start in range(0, min(len(header) - 2, 4096)):
                if header[search_start : search_start + 2] in (b"MM", b"II"):
                    magic_offset = search_start + 2
                    if magic_offset + 2 <= len(header):
                        try:
                            be = header[search_start : search_start + 2] == b"MM"
                            fmt = ">H" if be else "<H"
                            magic = struct.unpack_from(fmt, header, magic_offset)[0]
                            if magic == 42:
                                exif_data = header[search_start:]
                                exif_offset = 0
                                break
                        except Exception:
                            continue

        if exif_data is None:
            return None

        # Parse TIFF header
        byte_order = exif_data[exif_offset : exif_offset + 2]
        big_endian = byte_order == b"MM"
        if byte_order not in (b"MM", b"II"):
            return None

        magic = _read_uint16(exif_data, exif_offset + 2, big_endian)
        if magic != 42:
            return None

        ifd_offset = _read_uint32(exif_data, exif_offset + 4, big_endian)

        # Walk IFD0 to find ExifIFD pointer (tag 0x8769)
        exif_ifd_offset = _find_tag_in_ifd(exif_data, exif_offset, ifd_offset, 0x8769, big_endian)

        if exif_ifd_offset is not None:
            date_str = _find_string_tag_in_ifd(exif_data, exif_offset, exif_ifd_offset, 0x9003, big_endian)
            if date_str:
                return _parse_exif_datetime(date_str)
            date_str = _find_string_tag_in_ifd(exif_data, exif_offset, exif_ifd_offset, 0x9004, big_endian)
            if date_str:
                return _parse_exif_datetime(date_str)

        date_str = _find_string_tag_in_ifd(exif_data, exif_offset, ifd_offset, 0x0132, big_endian)
        if date_str:
            return _parse_exif_datetime(date_str)

        return None

    except Exception:
        return None


def _find_tag_in_ifd(data, base, ifd_offset, target_tag, big_endian):
    """Find a tag in an IFD and return its value as uint32."""
    try:
        abs_offset = base + ifd_offset
        num_entries = _read_uint16(data, abs_offset, big_endian)
        for i in range(num_entries):
            entry_offset = abs_offset + 2 + i * 12
            tag = _read_uint16(data, entry_offset, big_endian)
            if tag == target_tag:
                value = _read_uint32(data, entry_offset + 8, big_endian)
                return value
        return None
    except Exception:
        return None


def _find_string_tag_in_ifd(data, base, ifd_offset, target_tag, big_endian):
    """Find a string tag in an IFD and return its value."""
    try:
        abs_offset = base + ifd_offset
        num_entries = _read_uint16(data, abs_offset, big_endian)
        for i in range(num_entries):
            entry_offset = abs_offset + 2 + i * 12
            tag = _read_uint16(data, entry_offset, big_endian)
            if tag == target_tag:
                typ = _read_uint16(data, entry_offset + 2, big_endian)
                count = _read_uint32(data, entry_offset + 4, big_endian)
                if typ == 2:  # ASCII
                    if count <= 4:
                        str_data = data[entry_offset + 8 : entry_offset + 8 + count]
                    else:
                        str_offset = _read_uint32(data, entry_offset + 8, big_endian)
                        str_data = data[base + str_offset : base + str_offset + count]
                    return str_data.rstrip(b"\x00").decode("ascii", errors="ignore")
        return None
    except Exception:
        return None


def _parse_exif_datetime(s):
    """Parse EXIF datetime string."""
    try:
        return datetime.strptime(s.strip(), "%Y:%m:%d %H:%M:%S")
    except ValueError:
        try:
            return datetime.strptime(s.strip(), "%Y-%m-%d %H:%M:%S")
        except ValueError:
            return None


# ── File Finder ─────────────────────────────────────────────────


def find_raw_files(input_path, recursive=False):
    """Find all supported photo files (RAW/JPG/HEIC) in directory."""
    input_path = Path(input_path)
    if input_path.is_file():
        if input_path.suffix.lower() in SUPPORTED_EXTENSIONS:
            return [input_path]
        return []

    results = []
    if recursive:
        for p in sorted(input_path.rglob("*")):
            if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS:
                results.append(p)
    else:
        for p in sorted(input_path.iterdir()):
            if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS:
                results.append(p)
    return results


def get_file_dates(raw_files, workers=None):
    """Read EXIF dates from multiple RAW files in parallel."""
    max_workers = workers or min(os.cpu_count() or 4, 16)
    results = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {executor.submit(read_exif_date, f): f for f in raw_files}
        for future in as_completed(futures):
            raw_path = futures[future]
            try:
                dt = future.result()
            except Exception:
                dt = None
            results.append((raw_path, dt))

    results.sort(key=lambda x: x[0].name)
    return results


# ── Date Parsing ────────────────────────────────────────────────


def parse_date_arg(date_str, reference_year=None):
    """
    Parse a flexible date string into a date object.

    Supported formats:
        2026-03-15, 2026/03/15, 03-15, 3-15, 0315,
        3月15日, 3月15, today, yesterday, "3 days ago"
    """
    if reference_year is None:
        reference_year = datetime.now().year

    s = date_str.strip()

    if s.lower() == "today":
        return date.today()
    if s.lower() == "yesterday":
        return date.today() - timedelta(days=1)

    m = re.match(r"(\d+)\s*days?\s*ago", s, re.IGNORECASE)
    if m:
        return date.today() - timedelta(days=int(m.group(1)))

    m = re.match(r"(\d{1,2})月(\d{1,2})日?$", s)
    if m:
        month, day = int(m.group(1)), int(m.group(2))
        return date(reference_year, month, day)

    m = re.match(r"(\d{4})[-/](\d{1,2})[-/](\d{1,2})$", s)
    if m:
        return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))

    m = re.match(r"(\d{1,2})[-/](\d{1,2})$", s)
    if m:
        month, day = int(m.group(1)), int(m.group(2))
        return date(reference_year, month, day)

    m = re.match(r"(\d{2})(\d{2})$", s)
    if m:
        month, day = int(m.group(1)), int(m.group(2))
        if 1 <= month <= 12 and 1 <= day <= 31:
            return date(reference_year, month, day)

    raise ValueError(
        f"无法解析日期: '{date_str}'。支持格式: 2026-03-15, 03-15, 3月15日, today, yesterday, '3 days ago'"
    )


# ── Timelapse Sequence Detection ────────────────────────────────


def detect_timelapse_sequences(file_dates, min_sequence=30, interval_tolerance=0.5):
    """Detect timelapse sequences by finding runs of regular shooting intervals.

    Algorithm:
      1. Sort files by EXIF datetime
      2. Compute intervals between consecutive shots
      3. Scan for runs where the interval stays within median ± tolerance
      4. Return sequences that have at least `min_sequence` frames

    Args:
        file_dates: list of (path, datetime) tuples (None datetimes are skipped)
        min_sequence: minimum number of frames to qualify as a timelapse sequence
        interval_tolerance: max allowed deviation from median interval (as fraction, e.g. 0.5 = 50%)

    Returns:
        list of sequences, each a list of (path, datetime) tuples, sorted by time
    """
    # Filter out files without EXIF time, sort by datetime
    timed = [(p, dt) for p, dt in file_dates if dt is not None]
    timed.sort(key=lambda x: x[1])

    if len(timed) < min_sequence:
        return []

    # Compute intervals in seconds
    intervals = []
    for i in range(1, len(timed)):
        delta = (timed[i][1] - timed[i - 1][1]).total_seconds()
        intervals.append(delta)

    # Scan for consistent-interval runs
    sequences = []
    i = 0
    n = len(intervals)

    while i < n:
        # Start a candidate run from position i
        # Use a small look-ahead window to estimate the interval
        window_size = min(10, n - i)
        if window_size < 2:
            i += 1
            continue

        # Get initial interval estimate from first few frames
        window_intervals = sorted(intervals[i : i + window_size])
        # Use median of the window
        median_interval = window_intervals[len(window_intervals) // 2]

        # Skip if interval is too large (> 60s) or too small (< 0.5s)
        if median_interval < 0.5 or median_interval > 60:
            i += 1
            continue

        # Extend the run as long as intervals match
        lo = median_interval * (1 - interval_tolerance)
        hi = median_interval * (1 + interval_tolerance)

        run_start = i  # index into intervals array; frame index = run_start
        j = i
        while j < n and lo <= intervals[j] <= hi:
            j += 1

        run_length = j - run_start + 1  # number of frames = intervals + 1

        if run_length >= min_sequence:
            # Frame indices: run_start to run_start + run_length (inclusive in timed[])
            seq = timed[run_start : run_start + run_length]
            sequences.append(seq)
            i = j  # skip past this sequence
        else:
            i += 1

    # Merge overlapping/adjacent sequences
    if len(sequences) > 1:
        merged = [sequences[0]]
        for seq in sequences[1:]:
            prev = merged[-1]
            # If this sequence starts before the previous one ends, merge
            if seq[0][1] <= prev[-1][1]:
                # Combine and deduplicate
                combined = {str(p): (p, dt) for p, dt in prev}
                for p, dt in seq:
                    combined[str(p)] = (p, dt)
                merged[-1] = sorted(combined.values(), key=lambda x: x[1])
            else:
                merged.append(seq)
        sequences = merged

    return sequences


def _format_interval(seconds):
    """Format interval in seconds to a human-readable string."""
    if seconds < 1:
        return f"{seconds*1000:.0f}ms"
    if seconds < 60:
        return f"{seconds:.1f}s"
    minutes = int(seconds) // 60
    secs = seconds - minutes * 60
    return f"{minutes}m{secs:.0f}s"


# ── Main ────────────────────────────────────────────────────────


def main():
    parser = argparse.ArgumentParser(
        description="按拍照日期查找相机 RAW 文件（支持 NEF/CR2/CR3/ARW/RAF/ORF/DNG 等）",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  %(prog)s --date 3月15日
  %(prog)s --date 2026-03-15
  %(prog)s --from 2026-03-10 --to 2026-03-15
  %(prog)s --date 3月15日 --output result.json
  %(prog)s --list-dates

  # Timelapse: detect sequences with regular shooting intervals
  %(prog)s ~/Photos/RAW --timelapse
  %(prog)s ~/Photos/RAW --date today --timelapse --output timelapse.json
  %(prog)s ~/Photos/RAW --timelapse --min-sequence 50
        """,
    )

    parser.add_argument(
        "input", nargs="?", default=None, help="RAW 文件所在目录（默认: config.toml 的 input_dir 或 ~/Downloads/RAW）"
    )
    parser.add_argument("--config", type=str, default=None, help="config.toml 路径")

    date_group = parser.add_argument_group("日期过滤")
    date_group.add_argument("--date", "-d", type=str, default=None, help="精确日期")
    date_group.add_argument("--from", dest="date_from", type=str, default=None, help="起始日期（含）")
    date_group.add_argument("--to", dest="date_to", type=str, default=None, help="结束日期（含）")
    date_group.add_argument("--list-dates", action="store_true", help="列出所有 RAW 文件的拍照日期")

    timelapse_group = parser.add_argument_group("延时序列检测")
    timelapse_group.add_argument(
        "--timelapse", action="store_true", help="检测拍摄间隔规律的延时序列（自动排除散拍照片）"
    )
    timelapse_group.add_argument("--min-sequence", type=int, default=30, help="延时序列最小帧数（默认: 30）")
    timelapse_group.add_argument(
        "--interval-tolerance", type=float, default=0.5, help="间隔容差比例，如 0.5 = 允许 ±50%% 偏差（默认: 0.5）"
    )

    action_group = parser.add_argument_group("输出操作")
    action_group.add_argument("--output", "-o", type=str, default=None, help="结果输出 JSON 路径（默认: stdout）")

    parser.add_argument("--recursive", "-r", action="store_true", help="递归搜索子目录")
    parser.add_argument("--workers", type=int, default=None, help="并行工作线程数")

    args = parser.parse_args()

    if not args.date and not args.date_from and not args.date_to and not args.list_dates and not args.timelapse:
        parser.error("请指定 --date、--from/--to、--list-dates 或 --timelapse 之一")

    cfg = load_config(args.config)
    input_raw = args.input or cfg.get("raw_dir") or cfg.get("input_dir", "~/Downloads/RAW")
    input_path = Path(input_raw).expanduser().resolve()

    if not input_path.exists():
        print(f"❌ 输入目录不存在: {input_path}", file=sys.stderr)
        sys.exit(1)

    target_date = None
    from_date = None
    to_date = None

    if args.date:
        target_date = parse_date_arg(args.date)
    if args.date_from:
        from_date = parse_date_arg(args.date_from)
    if args.date_to:
        to_date = parse_date_arg(args.date_to)

    raw_files = find_raw_files(input_path, args.recursive)
    if not raw_files:
        print("📷 未找到 RAW 文件。")
        sys.exit(0)

    # Summarize formats found
    ext_counts = {}
    for f in raw_files:
        ext = f.suffix.upper()
        ext_counts[ext] = ext_counts.get(ext, 0) + 1
    ext_summary = ", ".join(f"{ext}: {cnt}" for ext, cnt in sorted(ext_counts.items()))
    print(f"📷 扫描 {len(raw_files)} 个 RAW 文件的 EXIF 日期... ({ext_summary})", file=sys.stderr)

    start_time = time.monotonic()
    file_dates = get_file_dates(raw_files, args.workers)
    scan_elapsed = time.monotonic() - start_time

    if args.list_dates:
        _print_date_list(file_dates, args)
        return

    # ── Timelapse detection mode ─────────────────────────────────
    if args.timelapse:
        # Optionally pre-filter by date/time range first
        if target_date or from_date or to_date:
            pre_filtered = []
            for raw_path, dt in file_dates:
                if dt is None:
                    continue
                shot_date = dt.date()
                if target_date and shot_date != target_date:
                    continue
                if from_date and shot_date < from_date:
                    continue
                if to_date and shot_date > to_date:
                    continue
                pre_filtered.append((raw_path, dt))
            scan_input = pre_filtered
        else:
            scan_input = file_dates

        sequences = detect_timelapse_sequences(
            scan_input,
            min_sequence=args.min_sequence,
            interval_tolerance=args.interval_tolerance,
        )

        if not sequences:
            print(f"\n😔 未检测到延时序列（最小帧数: {args.min_sequence}）", file=sys.stderr)
            timed_count = sum(1 for _, dt in scan_input if dt is not None)
            print(f"   共分析 {timed_count} 张有 EXIF 时间的照片", file=sys.stderr)
            sys.exit(1)

        # Report all found sequences
        total_frames = 0
        all_matched = []
        for seq_idx, seq in enumerate(sequences, 1):
            # Compute interval stats for this sequence
            seq_intervals = [(seq[i][1] - seq[i - 1][1]).total_seconds() for i in range(1, len(seq))]
            median_interval = sorted(seq_intervals)[len(seq_intervals) // 2]
            start_time_str = seq[0][1].strftime("%Y-%m-%d %H:%M:%S")
            end_time_str = seq[-1][1].strftime("%H:%M:%S")
            duration = (seq[-1][1] - seq[0][1]).total_seconds()

            print(f"\n🎬 延时序列 #{seq_idx}: {len(seq)} 帧", file=sys.stderr)
            print(f"   时间: {start_time_str} → {end_time_str} ({_format_interval(duration)})", file=sys.stderr)
            print(f"   间隔: ~{_format_interval(median_interval)}/帧", file=sys.stderr)
            print(f"   首帧: {seq[0][0].name}", file=sys.stderr)
            print(f"   末帧: {seq[-1][0].name}", file=sys.stderr)

            total_frames += len(seq)
            all_matched.extend(seq)

        # Compute how many were excluded
        timed_total = sum(1 for _, dt in scan_input if dt is not None)
        excluded = timed_total - total_frames
        print(f"\n✅ 共 {len(sequences)} 个延时序列，{total_frames} 帧", file=sys.stderr)
        if excluded > 0:
            print(f"   排除散拍照片: {excluded} 张", file=sys.stderr)

        # Always output JSON to stdout
        result = {
            "mode": "timelapse",
            "sequences": [],
            "total_frames": total_frames,
            "excluded": excluded,
            "scan_time_seconds": round(scan_elapsed, 2),
        }
        for seq_idx, seq in enumerate(sequences, 1):
            seq_intervals = [(seq[i][1] - seq[i - 1][1]).total_seconds() for i in range(1, len(seq))]
            median_interval = sorted(seq_intervals)[len(seq_intervals) // 2]
            result["sequences"].append(
                {
                    "index": seq_idx,
                    "frame_count": len(seq),
                    "start": seq[0][1].strftime("%Y-%m-%d %H:%M:%S"),
                    "end": seq[-1][1].strftime("%Y-%m-%d %H:%M:%S"),
                    "interval_seconds": round(median_interval, 2),
                    "files": [
                        {"file": p.name, "path": str(p), "datetime": dt.strftime("%Y-%m-%d %H:%M:%S")} for p, dt in seq
                    ],
                }
            )
        json_str = json.dumps(result, ensure_ascii=False, indent=2)
        print(json_str)

        if args.output:
            output_path = Path(args.output).expanduser().resolve()
            output_path.parent.mkdir(parents=True, exist_ok=True)
            output_path.write_text(json_str, encoding="utf-8")
            print(f"   📄 结果已保存: {output_path}", file=sys.stderr)

        sys.exit(0 if all_matched else 1)

    matched = []
    no_date = []

    for raw_path, dt in file_dates:
        if dt is None:
            no_date.append(raw_path)
            continue
        shot_date = dt.date()
        if target_date:
            if shot_date == target_date:
                matched.append((raw_path, dt))
        else:
            if from_date and shot_date < from_date:
                continue
            if to_date and shot_date > to_date:
                continue
            matched.append((raw_path, dt))

    # Build query_info
    query_info = {}
    if target_date:
        query_info["date"] = target_date.isoformat()
    if from_date:
        query_info["from"] = from_date.isoformat()
    if to_date:
        query_info["to"] = to_date.isoformat()

    _output_result(matched, no_date, input_path, query_info, scan_elapsed, args)

    sys.exit(0 if matched else 1)


def _print_date_list(file_dates, args):
    """Print all files with their shooting dates."""
    items = []
    for raw_path, dt in sorted(file_dates, key=lambda x: (x[1] or datetime.min, x[0].name)):
        items.append(
            {
                "file": raw_path.name,
                "path": str(raw_path),
                "format": raw_path.suffix.upper().lstrip("."),
                "date": dt.strftime("%Y-%m-%d") if dt else None,
                "datetime": dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None,
            }
        )

    # JSON to stdout
    json_str = json.dumps(items, ensure_ascii=False, indent=2)
    print(json_str)

    # Human-readable to stderr
    by_date = {}
    no_date = []
    for raw_path, dt in file_dates:
        if dt is None:
            no_date.append(raw_path)
        else:
            key = dt.date()
            by_date.setdefault(key, []).append((raw_path, dt))

    for d in sorted(by_date.keys()):
        files = by_date[d]
        print(f"\n📅 {d.strftime('%Y-%m-%d')} ({len(files)} 张)", file=sys.stderr)
        for raw_path, dt in sorted(files, key=lambda x: x[1]):
            ext = raw_path.suffix.upper().lstrip(".")
            print(f"   {dt.strftime('%H:%M:%S')}  {raw_path.name} [{ext}]", file=sys.stderr)

    if no_date:
        print(f"\n⚠️  无法读取日期 ({len(no_date)} 张)", file=sys.stderr)
        for p in no_date:
            print(f"   {p.name}", file=sys.stderr)

    # Write to --output file
    if args.output:
        output_path = Path(args.output).expanduser().resolve()
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(json_str, encoding="utf-8")
        print(f"   📄 结果已保存: {output_path}", file=sys.stderr)


def _output_result(matched, no_date, raw_root, query_info, scan_elapsed, args):
    """输出结果：JSON 到 stdout（和 --output 文件），人类可读到 stderr。"""
    result = {
        "raw_root": str(raw_root),
        "query": query_info,
        "files": [str(p) for p, _dt in sorted(matched, key=lambda x: x[1])],
        "file_count": len(matched),
        "no_date_count": len(no_date),
        "scan_time_seconds": round(scan_elapsed, 2),
    }

    # 人类可读输出到 stderr
    date_desc = query_info.get("date") or query_info.get("from", "") + " ~ " + query_info.get("to", "")
    print(f"\n🔍 日期过滤: {date_desc}", file=sys.stderr)
    print(f"   找到 {len(matched)} 张照片，耗时 {scan_elapsed:.1f}s", file=sys.stderr)
    if no_date:
        print(f"   ⚠️  {len(no_date)} 个文件无法读取 EXIF 日期", file=sys.stderr)

    # JSON 输出到 stdout
    json_str = json.dumps(result, ensure_ascii=False, indent=2)
    print(json_str)

    # 同时写入 --output 文件
    if args.output:
        output_path = Path(args.output).expanduser().resolve()
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(json_str, encoding="utf-8")
        print(f"   📄 结果已保存: {output_path}", file=sys.stderr)


if __name__ == "__main__":
    main()


path: scripts/layout_preview.py
content: #!/usr/bin/env python3
"""
Layout Preview Generator — Compare original vs graded photos side-by-side,
or compose graded photos into a grid preview.

Default mode: side-by-side comparison (left=original, right=graded)
Grid mode: use --grid flag for 4/9-grid layout (宫格)

Dependencies:
    Python: pip install pillow

    Check & install: bash scripts/setup_deps.sh

Usage:
    # Default: side-by-side comparison (left=original, right=graded)
    python layout_preview.py ~/data/output/{session-id}/graded \
        --originals ~/data/RAW \
        --params ~/data/output/{session-id}/grading_params.json

    # Grid mode (宫格): only graded photos
    python layout_preview.py ~/data/output/{session-id}/graded --grid \
        --params ~/data/output/{session-id}/grading_params.json
"""

import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from file_matcher import find_original_for_graded

try:
    from PIL import Image, ImageDraw, ImageFont
except ImportError:
    print("❌ Missing dependency: pillow", file=sys.stderr)
    print("   Install with: pip3 install pillow", file=sys.stderr)
    _SKILL_DIR = Path(__file__).resolve().parent.parent
    print(f"   Or run: bash {_SKILL_DIR}/scripts/setup_deps.sh", file=sys.stderr)
    sys.exit(1)


# ── Image Helpers ───────────────────────────────────────────────


def center_crop_square(img):
    """Center-crop an image to a square (1:1 ratio)."""
    w, h = img.size
    side = min(w, h)
    left = (w - side) // 2
    top = (h - side) // 2
    return img.crop((left, top, left + side, top + side))


def resize_to_fit(img, max_w, max_h):
    """Resize image to fit within max_w × max_h, preserving aspect ratio."""
    w, h = img.size
    ratio = min(max_w / w, max_h / h)
    if ratio >= 1.0:
        return img.copy()
    new_w = int(w * ratio)
    new_h = int(h * ratio)
    return img.resize((new_w, new_h), Image.Resampling.LANCZOS)


def _find_originals_from_params(params_json):
    """从 grading_params.json 获取 originals 绝对路径映射。"""
    if not params_json:
        return {}
    params_path = Path(params_json).expanduser().resolve()
    if not params_path.exists():
        return {}
    try:
        with open(params_path, "r", encoding="utf-8") as f:
            data = json.load(f)
        if isinstance(data, dict):
            data = [data]
        mapping = {}
        for item in data:
            file_ref = item.get("file", "")
            if file_ref:
                file_path = Path(file_ref)
                # 用 stem 做匹配 key（包括子目录前缀的也匹配）
                mapping[file_path.stem.lower()] = file_path
                mapping[file_path.name.lower()] = file_path
        return mapping
    except Exception:
        return {}


def find_graded_images(graded_dir, params_json=None):
    """
    Find graded JPG files, optionally ordered by grading_params.json.
    Returns list of (graded_path, original_filename_stem) tuples.
    """
    graded_dir = Path(graded_dir).expanduser().resolve()
    if not graded_dir.exists():
        return []

    all_jpgs = {}
    for p in graded_dir.iterdir():
        if p.is_file() and p.suffix.lower() in (".jpg", ".jpeg"):
            all_jpgs[p.stem.lower()] = p
            # Also index by original stem (before style suffix)
            # e.g. "DSC_0001_暖春丝滑" → "DSC_0001"
            # Also handle subdirectory prefix: "001_DSC_0001_暖春丝滑" → "DSC_0001"
            parts = p.stem.rsplit("_", 1)
            if len(parts) > 1:
                all_jpgs[parts[0].lower()] = p
                # Try stripping subdirectory prefix too: "001_DSC_0001" → "DSC_0001"
                sub_parts = parts[0].rsplit("_", 1)
                if len(sub_parts) > 1:
                    all_jpgs[sub_parts[1].lower()] = p

    if not all_jpgs:
        return []

    if params_json:
        params_path = Path(params_json).expanduser().resolve()
        if params_path.exists():
            try:
                with open(params_path, "r", encoding="utf-8") as f:
                    data = json.load(f)
                if isinstance(data, dict):
                    data = [data]
                if isinstance(data, list):
                    ordered = []
                    seen = set()
                    for item in data:
                        file_name = item.get("file", "")
                        stem = Path(file_name).stem.lower()
                        if stem in all_jpgs:
                            p = all_jpgs[stem]
                            if p not in seen:
                                ordered.append(p)
                                seen.add(p)
                    if ordered:
                        return ordered
            except (json.JSONDecodeError, OSError):
                pass

    # Deduplicate: multiple keys may point to the same file path
    seen = set()
    unique = []
    for p in sorted(all_jpgs.values(), key=lambda p: p.name):
        if p not in seen:
            unique.append(p)
            seen.add(p)
    return unique


def _get_label_font(font_size):
    """Find and return a suitable font for labels."""
    for font_path in [
        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
        "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
        "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
        "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Bold.ttc",
        "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc",
    ]:
        if Path(font_path).exists():
            try:
                return ImageFont.truetype(font_path, font_size)
            except Exception:
                pass
    return ImageFont.load_default()


def add_label(
    img, text, position="bottom-left", font_size=28, color=(255, 255, 255), bg_color=(0, 0, 0, 160), padding=8
):
    """Add a text label overlay to an image. Returns a new image."""
    img = img.copy().convert("RGBA")
    overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(overlay)
    font = _get_label_font(font_size)

    bbox = draw.textbbox((0, 0), text, font=font)
    text_w = bbox[2] - bbox[0]
    text_h = bbox[3] - bbox[1]

    if position == "bottom-left":
        x = padding
        y = img.height - text_h - padding * 2
    elif position == "bottom-right":
        x = img.width - text_w - padding * 2
        y = img.height - text_h - padding * 2
    elif position == "top-left":
        x = padding
        y = padding
    elif position == "top-right":
        x = img.width - text_w - padding * 2
        y = padding
    else:
        x = padding
        y = img.height - text_h - padding * 2

    # Draw background rectangle
    draw.rectangle(
        [x - padding // 2, y - padding // 2, x + text_w + padding, y + text_h + padding],
        fill=bg_color,
    )
    draw.text((x, y), text, fill=color, font=font)

    return Image.alpha_composite(img, overlay).convert("RGB")


# ── Layout Generators ──────────────────────────────────────────


def generate_comparison(
    graded_paths,
    originals_dir,
    params_json=None,
    params_mapping=None,
    cell_height=800,
    gap=8,
    label_gap=4,
    bg_color=(245, 245, 245),
):
    """
    Generate side-by-side comparison images (original left, graded right).
    Multiple photos are stacked vertically, with a header bar and prominent labels.
    """
    pairs = []
    for gp in graded_paths:
        orig = None
        # Priority 1: from params_mapping (absolute paths in grading_params.json)
        if params_mapping:
            # Try full stem match first
            stem_lower = gp.stem.lower()
            # Strip style suffix: "DSC_0001_暖春丝滑" → "DSC_0001"
            parts = gp.stem.rsplit("_", 1)
            if len(parts) > 1:
                base_stem = parts[0].lower()
                # Also strip subdirectory prefix: "001_DSC_0001" → "DSC_0001"
                sub_parts = base_stem.rsplit("_", 1)
                if len(sub_parts) > 1:
                    base_stem = sub_parts[1].lower()
            else:
                base_stem = stem_lower

            for key in [base_stem, stem_lower]:
                if key in params_mapping:
                    ref_path = Path(params_mapping[key])
                    if ref_path.exists():
                        orig = ref_path
                        break

        # Priority 2: from originals_dir
        if orig is None and originals_dir:
            orig = find_original_for_graded(gp, originals_dir, params_json)
        pairs.append((orig, gp))

    if not pairs:
        return None

    # Label styling — scale font with cell_height
    label_font_size = max(24, cell_height // 16)
    header_height = label_font_size + 20  # Top header bar height
    divider_width = max(4, gap)  # Vertical divider between BEFORE/AFTER

    # Each row: [original | graded], both fit into cell_height
    row_images = []
    for orig_path, graded_path in pairs:
        try:
            graded_img = Image.open(graded_path).convert("RGB")
        except Exception as e:
            print(f"   ⚠️  Failed to load graded {graded_path.name}: {e}")
            continue

        # Scale graded to cell_height
        g_w, g_h = graded_img.size
        scale = cell_height / g_h
        target_w = int(g_w * scale)
        target_h = cell_height
        graded_resized = graded_img.resize((target_w, target_h), Image.Resampling.LANCZOS)

        if orig_path and orig_path.exists():
            try:
                orig_img = Image.open(orig_path).convert("RGB")
                orig_resized = orig_img.resize((target_w, target_h), Image.Resampling.LANCZOS)
            except Exception:
                orig_resized = Image.new("RGB", (target_w, target_h), (200, 200, 200))
        else:
            orig_resized = Image.new("RGB", (target_w, target_h), (200, 200, 200))

        # Add corner labels on images
        orig_labeled = add_label(
            orig_resized,
            "BEFORE",
            position="top-left",
            font_size=label_font_size,
            bg_color=(80, 80, 80, 200),
            padding=12,
        )
        graded_labeled = add_label(
            graded_resized,
            "AFTER",
            position="top-right",
            font_size=label_font_size,
            bg_color=(0, 120, 60, 200),
            padding=12,
        )

        row_images.append((orig_labeled, graded_labeled, target_w, target_h))

    if not row_images:
        return None

    # Calculate canvas size
    max_pair_width = max(tw for _, _, tw, _ in row_images)
    canvas_w = max_pair_width * 2 + divider_width
    total_image_h = sum(th for _, _, _, th in row_images) + label_gap * (len(row_images) - 1)
    canvas_h = total_image_h

    canvas = Image.new("RGB", (canvas_w, canvas_h), bg_color)

    y_offset = 0
    for orig_img, graded_img, tw, th in row_images:
        # Center each pair horizontally if narrower than max
        x_left = (max_pair_width - tw) // 2
        x_right = max_pair_width + divider_width + (max_pair_width - tw) // 2

        canvas.paste(orig_img, (x_left, y_offset))
        canvas.paste(graded_img, (x_right, y_offset))

        # Draw vertical divider line
        draw = ImageDraw.Draw(canvas)
        divider_x = max_pair_width
        draw.rectangle(
            [divider_x, y_offset, divider_x + divider_width - 1, y_offset + th - 1],
            fill=(220, 220, 220),
        )

        y_offset += th + label_gap

    return canvas


def generate_grid(images, cell_size=800, gap=6, bg_color=(255, 255, 255)):
    """
    Compose images into a grid layout.

    Grid dimensions:
      1 photo: 1×1, 2: 1×2, 3: 1×3, 4: 2×2, 5-6: 2×3, 7-9: 3×3
    """
    count = len(images)
    if count <= 1:
        rows, cols = 1, 1
    elif count == 2:
        rows, cols = 1, 2
    elif count == 3:
        rows, cols = 1, 3
    elif count == 4:
        rows, cols = 2, 2
    elif count <= 6:
        rows, cols = 2, 3
    else:
        rows, cols = 3, 3

    canvas_w = cols * cell_size + (cols - 1) * gap
    canvas_h = rows * cell_size + (rows - 1) * gap
    canvas = Image.new("RGB", (canvas_w, canvas_h), bg_color)

    for idx, img in enumerate(images[: rows * cols]):
        row = idx // cols
        col = idx % cols
        x = col * (cell_size + gap)
        y = row * (cell_size + gap)

        cropped = center_crop_square(img)
        resized = cropped.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
        canvas.paste(resized, (x, y))

    return canvas


# ── Main ────────────────────────────────────────────────────────


def main():
    parser = argparse.ArgumentParser(
        description="Generate layout preview: side-by-side comparison (default) or grid",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Modes:
  Default (comparison): Shows original (left) vs graded (right) for each photo.
                        Requires --originals to specify the original photos directory.

  Grid (--grid):        Classic grid layout (4宫格 / 9宫格).
                        Only shows graded photos.

Examples:
  # Side-by-side comparison (default)
  %(prog)s ~/data/output/20260324-155000/graded \\
      --originals ~/data/RAW \\
      --params ~/data/output/20260324-155000/grading_params.json

  # Grid mode (宫格)
  %(prog)s ~/data/output/20260324-155000/graded --grid \\
      --params ~/data/output/20260324-155000/grading_params.json
        """,
    )
    parser.add_argument("graded_dir", help="Directory containing graded JPG files")
    parser.add_argument("--originals", type=str, default=None, help="Originals 目录（仅当 params 中无绝对路径时需要）")
    parser.add_argument("--params", type=str, default=None, help="grading_params.json for photo ordering")
    parser.add_argument("--output", "-o", type=str, default=None, help="Output JPG path")
    parser.add_argument("--grid", action="store_true", help="Use grid layout (宫格) instead of side-by-side comparison")
    parser.add_argument(
        "--layout-config",
        type=str,
        default=None,
        help="layout_config.json for photo ordering and count (overrides --params ordering)",
    )
    parser.add_argument(
        "--cell-size", type=int, default=800, help="Grid cell size or comparison row height in pixels (default: 800)"
    )
    parser.add_argument("--gap", type=int, default=6, help="Gap between cells in pixels (default: 6)")
    parser.add_argument("--quality", type=int, default=92, help="JPEG quality for output (default: 92)")
    parser.add_argument(
        "--platform",
        type=str,
        default=None,
        choices=["wechat", "xiaohongshu"],
        help="Use platform-specific ordering from layout_config.json",
    )

    args = parser.parse_args()

    graded_dir = Path(args.graded_dir).expanduser().resolve()
    if not graded_dir.exists():
        print(f"❌ Graded directory not found: {graded_dir}", file=sys.stderr)
        sys.exit(1)

    # ── Load layout_config if provided ──────────────────────────
    layout_order = None
    layout_count = None
    if args.layout_config:
        lc_path = Path(args.layout_config).expanduser().resolve()
        if lc_path.exists():
            try:
                with open(lc_path, "r", encoding="utf-8") as f:
                    lc = json.load(f)
                platform = args.platform or "wechat"
                if platform in lc:
                    plat_cfg = lc[platform]
                    layout_order = plat_cfg.get("order", [])
                    layout_count = plat_cfg.get("count")
                    print(f"📋 Layout config: {platform} ({layout_count} photos)")
                elif "order" in lc:
                    layout_order = lc["order"]
                    layout_count = lc.get("count")
            except (json.JSONDecodeError, OSError) as e:
                print(f"   ⚠️  Failed to load layout config: {e}")

    image_paths = find_graded_images(graded_dir, args.params)

    # Apply layout_config ordering if available
    if layout_order and image_paths:
        stem_to_path = {}
        for p in image_paths:
            stem_to_path[p.stem.lower()] = p
            parts = p.stem.rsplit("_", 1)
            if len(parts) > 1:
                stem_to_path[parts[0].lower()] = p
        ordered = []
        seen = set()
        for fname in layout_order:
            stem = Path(fname).stem.lower()
            if stem in stem_to_path and stem_to_path[stem] not in seen:
                ordered.append(stem_to_path[stem])
                seen.add(stem_to_path[stem])
        if ordered:
            image_paths = ordered

    # Apply count limit
    if layout_count and layout_count < len(image_paths):
        image_paths = image_paths[:layout_count]
    if not image_paths:
        print("❌ No graded JPG files found.", file=sys.stderr)
        sys.exit(1)

    print(f"📸 Found {len(image_paths)} graded photo(s):")
    for i, p in enumerate(image_paths, 1):
        print(f"   {i}. {p.name}")

    if args.grid:
        # ── Grid mode (宫格) ──
        print(f"\n🎨 Generating {len(image_paths)}-photo grid preview...")
        images = []
        for p in image_paths:
            try:
                img = Image.open(p).convert("RGB")
                images.append(img)
            except Exception as e:
                print(f"   ⚠️  Failed to load {p.name}: {e}")

        if not images:
            print("❌ No images could be loaded.", file=sys.stderr)
            sys.exit(1)

        result = generate_grid(images, cell_size=args.cell_size, gap=args.gap)
        mode_label = "grid"
    else:
        # ── Comparison mode (default) ──
        # Get originals from grading_params.json if available
        params_mapping = _find_originals_from_params(args.params)

        originals_dir = args.originals
        if not originals_dir and not params_mapping:
            # Try to auto-detect: look in ~/data/RAW or thumbnails sibling dir
            candidates = [
                graded_dir.parent / "thumbnails",
                Path("~/data/RAW").expanduser().resolve(),
            ]
            for c in candidates:
                if c.exists() and any(c.iterdir()):
                    originals_dir = str(c)
                    print(f"   📂 Auto-detected originals: {c}")
                    break

        if not originals_dir and not params_mapping:
            print(
                "❌ No originals found. Use --originals or --params with absolute paths, or --grid for grid mode.",
                file=sys.stderr,
            )
            sys.exit(1)

        print(f"\n🔄 Generating side-by-side comparison (BEFORE | AFTER)...")
        if originals_dir:
            print(f"   Originals: {originals_dir}")
        if params_mapping:
            print(f"   Originals from params: {len(params_mapping)} file(s)")
        result = generate_comparison(
            image_paths,
            originals_dir,
            params_json=args.params,
            params_mapping=params_mapping,
            cell_height=args.cell_size,
            gap=args.gap,
        )

        if result is None:
            print("❌ Failed to generate comparison.", file=sys.stderr)
            sys.exit(1)

        mode_label = "comparison"

    if args.output:
        output_path = Path(args.output).expanduser().resolve()
    else:
        output_path = graded_dir.parent / "layout_preview.jpg"

    output_path.parent.mkdir(parents=True, exist_ok=True)
    result.save(output_path, "JPEG", quality=args.quality, optimize=True)

    size_kb = output_path.stat().st_size / 1024
    print(f"\n✅ Layout preview saved: {output_path}")
    print(f"   📐 {result.width}×{result.height}px, {size_kb:.0f}KB")
    print(f"   📸 {len(image_paths)} photos ({mode_label})")


if __name__ == "__main__":
    main()


path: scripts/setup_deps.sh
content: #!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# photo-toolkit — Dependency Check & Install
# ─────────────────────────────────────────────────────────────
set -euo pipefail

SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
REQUIREMENTS="$SKILL_DIR/requirements.txt"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "  📦 photo-toolkit — 依赖检查"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""

MISSING_SYS=()
MISSING_PY=()
ALL_OK=true

# ── 1. Check system dependency: libraw ──────────────────────
echo "🔍 检查系统依赖..."
if [ -f /usr/lib/libraw.so ] || [ -f /usr/lib64/libraw.so ] || [ -f /usr/lib/x86_64-linux-gnu/libraw.so ] || ldconfig -p 2>/dev/null | grep -q libraw; then
    echo -e "  ${GREEN}✓${NC} libraw (system)"
elif command -v brew &>/dev/null && brew list libraw &>/dev/null 2>&1; then
    echo -e "  ${GREEN}✓${NC} libraw (Homebrew)"
else
    echo -e "  ${RED}✗${NC} libraw — 未安装"
    MISSING_SYS+=("libraw")
    ALL_OK=false
fi

if command -v ffmpeg &>/dev/null; then
    FFMPEG_VER=$(ffmpeg -version 2>&1 | head -1 | awk '{print $3}')
    echo -e "  ${GREEN}✓${NC} ffmpeg ($FFMPEG_VER)"
else
    echo -e "  ${YELLOW}ℹ${NC} ffmpeg — 未安装（assemble.py 需要，可选）"
fi

# ── 2. Check Python dependencies ───────────────────────────
echo ""
echo "🔍 检查 Python 依赖..."

check_python_pkg() {
    local import_name="$1"
    local pip_name="$2"
    if python3 -c "import $import_name" 2>/dev/null; then
        local ver
        ver=$(python3 -c "import $import_name; print(getattr($import_name, '__version__', getattr($import_name, 'VERSION', '?')))" 2>/dev/null || echo "?")
        echo -e "  ${GREEN}✓${NC} $pip_name ($ver)"
    else
        echo -e "  ${RED}✗${NC} $pip_name — 未安装"
        MISSING_PY+=("$pip_name")
        ALL_OK=false
    fi
}

check_python_pkg "rawpy" "rawpy"
check_python_pkg "PIL" "pillow"
check_python_pkg "numpy" "numpy"

# ── 3. Summary & Install ───────────────────────────────────
echo ""
if $ALL_OK; then
    echo -e "${GREEN}✅ 所有依赖已就绪！${NC}"
    exit 0
fi

echo -e "${YELLOW}⚠️  缺少以下依赖：${NC}"

if [ ${#MISSING_SYS[@]} -gt 0 ]; then
    echo ""
    echo "  系统依赖:"
    for pkg in "${MISSING_SYS[@]}"; do
        echo "    - $pkg"
    done
    echo ""
    if [[ "$(uname)" == "Darwin" ]]; then
        echo "  安装命令: brew install ${MISSING_SYS[*]}"
    elif command -v dnf &>/dev/null; then
        echo "  安装命令: sudo dnf install LibRaw-devel"
    elif command -v yum &>/dev/null; then
        echo "  安装命令: sudo yum install LibRaw-devel"
    else
        echo "  安装命令: sudo apt-get install ${MISSING_SYS[*]/%/-dev}"
    fi
fi

if [ ${#MISSING_PY[@]} -gt 0 ]; then
    echo ""
    echo "  Python 依赖:"
    for pkg in "${MISSING_PY[@]}"; do
        echo "    - $pkg"
    done
    echo ""
    echo "  安装命令: pip3 install ${MISSING_PY[*]}"
fi

echo ""
read -r -p "是否立即安装缺少的依赖？[Y/n] " answer
answer=${answer:-Y}

if [[ "$answer" =~ ^[Yy]$ ]]; then
    # Install system deps
    if [ ${#MISSING_SYS[@]} -gt 0 ]; then
        echo ""
        echo "📦 安装系统依赖..."
        if [[ "$(uname)" == "Darwin" ]]; then
            brew install "${MISSING_SYS[@]}"
        elif command -v dnf &>/dev/null; then
            sudo dnf install -y LibRaw-devel
        elif command -v yum &>/dev/null; then
            sudo yum install -y LibRaw-devel
        else
            sudo apt-get install -y "${MISSING_SYS[@]/%/-dev}"
        fi
    fi

    # Install Python deps
    if [ ${#MISSING_PY[@]} -gt 0 ]; then
        echo ""
        echo "📦 安装 Python 依赖..."
        pip3 install "${MISSING_PY[@]}"
    fi

    echo ""
    echo -e "${GREEN}✅ 安装完成！${NC}"
else
    echo ""
    echo "跳过安装。请手动安装后重试。"
    exit 1
fi


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)