#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Pinpoint — interface graphique pour aligner une série de photos sur un point du
sujet (ex. l'œil d'un oiseau), pour qu'il tombe exactement au même pixel partout.

Installation (macOS) :
    python3 -m pip install PySide6 opencv-python numpy imageio-ffmpeg
    (imageio-ffmpeg fournit ffmpeg pour l'export vidéo ; un ffmpeg installé par
     brew install ffmpeg convient aussi)
    (optionnel, pour recopier les EXIF : brew install exiftool)

Lancement :
    python3 pinpoint.py                          puis « Ouvrir un dossier… »
    python3 pinpoint.py ~/Photos/serie           (dossier de photos)
    python3 pinpoint.py ~/Photos/serie/pinpoint_project.json  (projet)

Projet : tout le travail (points, référence, cadrage, réglages, options vidéo…) est
enregistré automatiquement dans « pinpoint_project.json » à côté des photos. En rouvrant
le dossier, le travail reprend là où il s'était arrêté. « Enregistrer le projet sous… »
permet de le mettre ailleurs ; il retrouve alors les photos par chemin relatif ou absolu.
"""

__version__ = "1.0.0"

import json
import math
import os
import shutil
import subprocess
import sys
import tempfile
import wave
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import cv2
import numpy as np
from PySide6.QtCore import (QEvent, QObject, QPointF, QRectF, QSize, Qt, QThread,
                            QTimer, QUrl, Signal)
from PySide6.QtGui import (QColor, QDesktopServices, QIcon, QImage, QKeySequence,
                           QPainter, QPainterPath, QPen, QPixmap, QShortcut, QTransform)
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDoubleSpinBox,
                               QFileDialog, QFormLayout, QGraphicsItem,
                               QGraphicsLineItem, QGraphicsPathItem,
                               QGraphicsPixmapItem, QGraphicsRectItem, QGraphicsScene,
                               QGraphicsView, QGroupBox, QHBoxLayout, QLabel,
                               QListWidget, QListWidgetItem, QMainWindow, QMessageBox,
                               QProgressBar, QPushButton, QScrollArea, QSlider,
                               QSpinBox, QSplitter, QVBoxLayout, QWidget)

APP = "Pinpoint"
EXTS = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
PROJECT_FILE = "pinpoint_project.json"
# Anciens noms du fichier de projet (l'outil s'appelait « Aligneur ») : repris à l'ouverture du
# dossier, puis enregistrés sous PROJECT_FILE.
LEGACY_FILES = ("aligneur_projet.json", ".align_points.json")
PROJECT_VERSION = 2
EYE_COLOR = QColor(255, 210, 0)
REF2_COLOR = QColor(255, 60, 200)
FRAME_COLOR = QColor(0, 220, 255)

RATIOS = [("Libre", None), ("3:2", 3 / 2), ("2:3", 2 / 3), ("4:3", 4 / 3), ("3:4", 3 / 4),
          ("16:9", 16 / 9), ("1:1", 1.0), ("4:5", 4 / 5), ("5:4", 5 / 4)]
TARGETS = [("Comme dans la référence", None), ("Centre", (0.5, 0.5)),
           ("Tiers haut gauche", (1 / 3, 1 / 3)), ("Tiers haut droit", (2 / 3, 1 / 3)),
           ("Tiers bas gauche", (1 / 3, 2 / 3)), ("Tiers bas droit", (2 / 3, 2 / 3)),
           ("Personnalisée", "custom")]
FORMATS = [("Format d'origine", "orig"), ("JPEG", "jpg"), ("TIFF", "tif")]


# ============================================================== images
def load_image(path):
    img = cv2.imread(str(path), cv2.IMREAD_ANYCOLOR | cv2.IMREAD_ANYDEPTH)
    if img is None:
        raise ValueError(f"Impossible de lire {Path(path).name}")
    if img.ndim == 2:
        img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
    elif img.shape[2] == 4:
        img = img[:, :, :3]
    return img


def to8(img):
    if img.dtype == np.uint8:
        return img
    if img.dtype == np.uint16:
        return (img >> 8).astype(np.uint8)
    return cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)


def qimage_from_bgr(img8):
    rgb = np.ascontiguousarray(cv2.cvtColor(img8, cv2.COLOR_BGR2RGB))
    h, w = rgb.shape[:2]
    return QImage(rgb.data, w, h, 3 * w, QImage.Format.Format_RGB888).copy()


def load_for_display(path):
    img = load_image(path)
    return to8(img), (img.shape[1], img.shape[0])


# ============================================================== réglages
ADJ_SPECS = [  # clé, libellé, min, max, diviseur, format
    ("exposure", "Exposition", -300, 300, 100, "{:+.2f} EV"),
    ("contrast", "Contraste", -100, 100, 1, "{:+d}"),
    ("highlights", "Hautes lumières", -100, 100, 1, "{:+d}"),
    ("shadows", "Ombres", -100, 100, 1, "{:+d}"),
    ("saturation", "Saturation", -100, 100, 1, "{:+d}"),
    ("temperature", "Température", -100, 100, 1, "{:+d}"),
]
ADJ_KEYS = [k for k, *_ in ADJ_SPECS]


def adj_identity(adj):
    return not adj or all(abs(adj.get(k, 0)) < 1e-9 for k in ADJ_KEYS)


def build_luts(adj, n):
    """Courbes par canal (ordre B, G, R), valeurs dans [0, 1]."""
    x = np.linspace(0.0, 1.0, n)
    ev = adj.get("exposure", 0.0)
    c = adj.get("contrast", 0) / 100
    hl = adj.get("highlights", 0) / 100
    sh = adj.get("shadows", 0) / 100
    t = adj.get("temperature", 0) / 100
    gains = (2 ** (-0.25 * t), 1.0, 2 ** (0.25 * t))          # B, G, R : + = plus chaud
    a = 2 ** (1.5 * c)
    luts = []
    for g in gains:
        y = np.clip((x ** 2.2) * g * 2 ** ev, 0, 1) ** (1 / 2.2)  # exposition en linéaire
        y = y + 0.14 * 6.75 * (sh * y * (1 - y) ** 2 + hl * y ** 2 * (1 - y))
        y = np.clip(y, 0, 1)
        ya = y ** a
        y = ya / (ya + (1 - y) ** a)                              # courbe en S autour de 50 %
        luts.append(np.clip(y, 0, 1))
    return np.stack(luts)


def apply_adjust(img, adj):
    """Applique les réglages globaux (uint8, uint16 ou float 0-1)."""
    if adj_identity(adj):
        return img
    dt = img.dtype
    if dt == np.uint8:
        lut = np.round(build_luts(adj, 256) * 255).astype(np.uint8)
        out = cv2.LUT(img, lut.T.reshape(256, 1, 3).copy())
    elif dt == np.uint16:
        lut = np.round(build_luts(adj, 65536) * 65535).astype(np.uint16)
        out = np.empty_like(img)
        for ch in range(3):
            out[..., ch] = lut[ch][img[..., ch]]
    else:
        lut = build_luts(adj, 4096)
        xs = np.linspace(0, 1, 4096)
        f = np.clip(img.astype(np.float32), 0, 1)
        out = np.empty_like(f)
        for ch in range(3):
            out[..., ch] = np.interp(f[..., ch], xs, lut[ch])
        out = out.astype(dt)
    sat = adj.get("saturation", 0) / 100
    if abs(sat) > 1e-9:
        gray = cv2.cvtColor(out, cv2.COLOR_BGR2GRAY)
        out = cv2.addWeighted(out, 1 + sat, cv2.merge([gray, gray, gray]), -sat, 0)
    return out


def load_thumb(path, size=160):
    img = cv2.imread(str(path), cv2.IMREAD_REDUCED_COLOR_4)
    if img is None:
        img = to8(load_image(path))
    h, w = img.shape[:2]
    f = size / max(h, w)
    img = cv2.resize(img, (max(1, int(w * f)), max(1, int(h * f))), interpolation=cv2.INTER_AREA)
    return qimage_from_bgr(img)


# ============================================================== session
class ProjectError(Exception):
    pass


class FolderMissing(ProjectError):
    def __init__(self, data, project):
        super().__init__("Dossier des photos introuvable")
        self.data, self.project = data, project


def read_project(path):
    try:
        d = json.loads(Path(path).read_text())
    except Exception as e:
        raise ProjectError(f"Fichier de projet illisible :\n{path}\n{e}")
    if not isinstance(d, dict) or "images" not in d:
        raise ProjectError(f"Ce fichier n'est pas un projet {APP} :\n{path}")
    return d


def resolve_folder(d, project):
    cands = []
    if d.get("folder_rel"):
        cands.append((Path(project).parent / d["folder_rel"]).resolve())
    if d.get("folder"):
        cands.append(Path(d["folder"]).expanduser())
    if not cands:                       # ancien fichier .align_points.json : dans le dossier
        cands.append(Path(project).parent)
    return next((c for c in cands if c.is_dir()), None)


class Session:
    """Un dossier de photos + le fichier projet qui mémorise le travail."""

    def __init__(self, folder: Path, project: Path = None, data=None):
        self.folder = Path(folder).resolve()
        self.project = Path(project) if project else self.folder / PROJECT_FILE
        self.names = sorted(p.name for p in self.folder.iterdir()
                            if p.is_file() and p.suffix.lower() in EXTS and not p.name.startswith("."))
        self.images, self.settings, self.extra = {}, {}, {}
        self.ref = self.names[0] if self.names else None
        self.current = None
        self.resumed = False
        self.last_error = None
        if data is None:
            for f in (self.project, *(self.folder / nom for nom in LEGACY_FILES)):
                if f.exists():
                    try:
                        data = read_project(f)
                        break
                    except ProjectError:
                        continue
        if data:
            imgs = data.get("images", {})
            self.images = {k: v for k, v in imgs.items() if k in self.names}
            self.extra = {k: v for k, v in imgs.items() if k not in self.names}  # photos absentes : gardées
            if data.get("ref") in self.names:
                self.ref = data["ref"]
            self.settings = data.get("settings", {})
            self.current = data.get("current")
            self.resumed = bool(self.images)

    @classmethod
    def from_project(cls, project):
        project = Path(project).expanduser().resolve()
        d = read_project(project)
        folder = resolve_folder(d, project)
        if folder is None:
            raise FolderMissing(d, project)
        return cls(folder, project, d)

    def path(self, name):
        return self.folder / name

    def entry(self, name):
        return self.images.setdefault(name, {})

    def pts(self, name):
        return self.images.get(name, {}).get("pts", [])

    def skipped(self, name):
        return self.images.get(name, {}).get("skip", False)

    def complete(self, name, n):
        return not self.skipped(name) and len(self.pts(name)) >= n

    def save(self, settings, current=None):
        self.settings = settings
        if current:
            self.current = current
        try:
            rel = os.path.relpath(self.folder, self.project.parent)
        except ValueError:                       # autre volume
            rel = None
        d = {"app": APP, "version": PROJECT_VERSION,
             "folder": str(self.folder), "folder_rel": rel,
             "ref": self.ref, "current": self.current,
             "images": {**self.extra, **self.images}, "settings": settings}
        try:
            tmp = self.project.with_name(self.project.name + ".tmp")
            tmp.write_text(json.dumps(d, indent=1, ensure_ascii=False))
            os.replace(tmp, self.project)        # écriture atomique
            self.last_error = None
            return True
        except OSError as e:
            self.last_error = str(e)
            return False


class LayoutError(Exception):
    pass


def compute_layout(sess, n_pts, target, crop, aspect, zoom=1.0, center=None):
    """Transformations image -> sortie : o = s * p + (ox, oy), et rectangle de sortie.
    zoom : taille du cadre (fraction du cadre maximal) ; center : centre du cadre
    (coordonnées de sortie) ou None = centré sur la zone commune."""
    ref = sess.ref
    if not ref or not sess.complete(ref, n_pts):
        raise LayoutError("Place d'abord le(s) point(s) sur l'image de référence (★).")
    e = sess.images[ref]
    Wout, Hout = e["size"]
    rp = e["pts"]
    T = (target[0] * Wout, target[1] * Hout) if target else tuple(rp[0])
    d_ref = math.dist(rp[0], rp[1]) if n_pts == 2 else None
    if n_pts == 2 and d_ref < 1:
        raise LayoutError("Les deux repères de la référence sont confondus.")

    tf = {}
    ix0, iy0, ix1, iy1 = 0.0, 0.0, float(Wout), float(Hout)
    for name in sess.names:
        if not sess.complete(name, n_pts):
            continue
        e = sess.images[name]
        p, (W, H) = e["pts"], e["size"]
        s = 1.0
        if n_pts == 2:
            d = math.dist(p[0], p[1])
            if d < 1:
                continue
            s = d_ref / d
        ox, oy = T[0] - s * p[0][0], T[1] - s * p[0][1]
        tf[name] = (s, ox, oy)
        ix0, iy0 = max(ix0, ox), max(iy0, oy)
        ix1, iy1 = min(ix1, ox + s * W), min(iy1, oy + s * H)

    # zone autorisée : commune à toutes les images (recadrage) ou cadre de la référence
    if crop:
        bx0, by0, bx1, by1 = math.ceil(ix0), math.ceil(iy0), math.floor(ix1), math.floor(iy1)
        if bx1 - bx0 < 50 or by1 - by0 < 50:
            raise LayoutError("Zone commune trop petite : le sujet bouge trop. "
                              "Ignore les images extrêmes ou désactive le recadrage.")
    else:
        bx0, by0, bx1, by1 = 0, 0, Wout, Hout
    bw, bh = bx1 - bx0, by1 - by0

    # plus grand cadre au ratio demandé, puis taille réglable
    if aspect:
        w, h = (bh * aspect, bh) if bw / bh > aspect else (bw, bw / aspect)
    else:
        w, h = bw, bh
    zoom = min(max(zoom, 0.05), 1.0)
    w, h = max(20, int(round(w * zoom))), max(20, int(round(h * zoom)))
    w, h = min(w, bw), min(h, bh)

    # position : centre demandé, borné à la zone autorisée
    if center is None:
        cx, cy = (bx0 + bx1) / 2, (by0 + by1) / 2
    else:
        cx, cy = center
    cx0 = int(round(min(max(cx - w / 2, bx0), bx1 - w)))
    cy0 = int(round(min(max(cy - h / 2, by0), by1 - h)))
    return {"tf": tf, "rect": (cx0, cy0, cx0 + w, cy0 + h), "T": T,
            "bounds": (bx0, by0, bx1, by1)}


# ============================================================== vue image
def make_marker(color):
    path = QPainterPath()
    path.addEllipse(QPointF(0, 0), 10, 10)
    for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
        path.moveTo(dx * 4, dy * 4)
        path.lineTo(dx * 18, dy * 18)
    item = QGraphicsPathItem(path)
    pen = QPen(color, 2)
    pen.setCosmetic(True)
    item.setPen(pen)
    item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations, True)
    item.setZValue(4)
    item.hide()
    return item


class ImageView(QGraphicsView):
    clicked = Signal(float, float, bool)   # coordonnées image (centre pixel = entier), alt
    zoomChanged = Signal(float)
    frameDragged = Signal(float, float)    # déplacement du cadre (coordonnées image)
    frameDragEnded = Signal()
    frameDragStarted = Signal()

    def __init__(self):
        super().__init__()
        self.sc = QGraphicsScene(self)
        self.setScene(self.sc)
        self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
        self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
        self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
        self.setBackgroundBrush(QColor(28, 28, 30))
        self.setRenderHint(QPainter.RenderHint.Antialiasing, True)
        self.viewport().setCursor(Qt.CursorShape.CrossCursor)

        self.pix = QGraphicsPixmapItem()
        self.onion = QGraphicsPixmapItem()
        self.onion.setZValue(1)
        self.onion.hide()
        self.stack = QGraphicsPixmapItem()
        self.stack.setZValue(1.5)
        self.stack.setTransformationMode(Qt.TransformationMode.SmoothTransformation)
        self.stack.hide()
        self.frame = QGraphicsRectItem()
        pen = QPen(FRAME_COLOR, 1.5, Qt.PenStyle.DashLine)
        pen.setCosmetic(True)
        self.frame.setPen(pen)
        self.frame.setZValue(2)
        self.frame.hide()
        self.link = QGraphicsLineItem()
        lpen = QPen(REF2_COLOR, 1)
        lpen.setCosmetic(True)
        self.link.setPen(lpen)
        self.link.setZValue(3)
        self.link.hide()
        self.markers = [make_marker(EYE_COLOR), make_marker(REF2_COLOR)]
        for it in (self.pix, self.onion, self.stack, self.frame, self.link, *self.markers):
            self.sc.addItem(it)
        self.fit_mode = True
        self._press = None

    # ---- contenu
    def has_image(self):
        return not self.pix.pixmap().isNull()

    def set_image(self, qimg, center=None, size=None):
        """size = taille pleine résolution si qimg est un aperçu réduit."""
        W, H = size or (qimg.width(), qimg.height())
        self.replace_image(qimg, W)
        self.setSceneRect(QRectF(-W / 2, -H / 2, 2 * W, 2 * H))
        if self.fit_mode:
            self.fit()
        else:
            self.centerOn(center if center is not None else QPointF(W / 2, H / 2))
            self._update_smooth()

    def replace_image(self, qimg, full_w=None):
        self.pix.setPixmap(QPixmap.fromImage(qimg))
        self.pix.setScale((full_w or qimg.width()) / qimg.width())

    def set_points(self, pts):
        for i, m in enumerate(self.markers):
            if i < len(pts):
                m.setPos(pts[i][0] + 0.5, pts[i][1] + 0.5)
                m.show()
            else:
                m.hide()
        if len(pts) >= 2:
            self.link.setLine(pts[0][0] + .5, pts[0][1] + .5, pts[1][0] + .5, pts[1][1] + .5)
            self.link.show()
        else:
            self.link.hide()

    def set_frame(self, rect):
        if rect is None:
            self.frame.hide()
        else:
            self.frame.setRect(rect)
            self.frame.show()

    def set_stack(self, pixmap, scale=1.0, x=0.0, y=0.0):
        if pixmap is None:
            self.stack.hide()
            return
        if self.stack.pixmap().cacheKey() != pixmap.cacheKey():
            self.stack.setPixmap(pixmap)
        self.stack.setScale(scale)
        self.stack.setPos(x, y)
        self.stack.show()

    def set_onion(self, pixmap, k=1.0, dx=0.0, dy=0.0, opacity=0.5, pix_scale=1.0):
        if pixmap is None:
            self.onion.hide()
            return
        if self.onion.pixmap().cacheKey() != pixmap.cacheKey():
            self.onion.setPixmap(pixmap)
        self.onion.setScale(k * pix_scale)
        self.onion.setPos(dx, dy)
        self.onion.setOpacity(opacity)
        self.onion.show()
        self._update_smooth()

    # ---- zoom
    def zoom_level(self):
        return self.transform().m11() * self.devicePixelRatioF()

    def _update_smooth(self):
        mode = (Qt.TransformationMode.SmoothTransformation if self.transform().m11() < 1
                else Qt.TransformationMode.FastTransformation)
        self.pix.setTransformationMode(mode)
        self.onion.setTransformationMode(mode)
        self.zoomChanged.emit(self.zoom_level())

    def zoom_by(self, f):
        cur = self.transform().m11()
        new = min(max(cur * f, 0.01), 50.0)
        self.scale(new / cur, new / cur)
        self.fit_mode = False
        self._update_smooth()

    def fit(self):
        if self.has_image():
            self.fitInView(self.pix, Qt.AspectRatioMode.KeepAspectRatio)
            self.fit_mode = True
            self._update_smooth()

    def actual_size(self):
        d = 1 / self.devicePixelRatioF()
        c = self.mapToScene(self.viewport().rect().center())
        self.setTransform(QTransform.fromScale(d, d))
        self.centerOn(c)
        self.fit_mode = False
        self._update_smooth()

    def resizeEvent(self, ev):
        super().resizeEvent(ev)
        if self.fit_mode:
            self.fit()

    def wheelEvent(self, ev):
        mods = ev.modifiers()
        trackpad = not ev.pixelDelta().isNull()
        if trackpad and not (mods & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier)):
            super().wheelEvent(ev)       # défilement trackpad = déplacement
            return
        dy = ev.angleDelta().y()
        if dy:
            self.zoom_by(1.25 ** (dy / 120))

    def viewportEvent(self, ev):
        if ev.type() == QEvent.Type.NativeGesture and \
                ev.gestureType() == Qt.NativeGestureType.ZoomNativeGesture:
            self.zoom_by(1.0 + ev.value())
            return True
        return super().viewportEvent(ev)

    # ---- clic vs glisser ; ⇧ + glisser = déplacer le cadre
    def _scene_pos(self, pos):
        inv, _ = self.viewportTransform().inverted()
        return inv.map(pos)

    def mousePressEvent(self, ev):
        if (ev.button() == Qt.MouseButton.LeftButton and self.frame.isVisible()
                and ev.modifiers() & Qt.KeyboardModifier.ShiftModifier):
            self._frame_drag = self._scene_pos(ev.position())
            self.viewport().setCursor(Qt.CursorShape.SizeAllCursor)
            self.frameDragStarted.emit()
            return
        if ev.button() == Qt.MouseButton.LeftButton:
            self._press = ev.position()
        super().mousePressEvent(ev)

    def mouseMoveEvent(self, ev):
        if getattr(self, "_frame_drag", None) is not None:
            p = self._scene_pos(ev.position())
            d = p - self._frame_drag
            self._frame_drag = p
            self.frameDragged.emit(d.x(), d.y())
            return
        super().mouseMoveEvent(ev)

    def mouseReleaseEvent(self, ev):
        if getattr(self, "_frame_drag", None) is not None:
            self._frame_drag = None
            self.viewport().setCursor(Qt.CursorShape.CrossCursor)
            self.frameDragEnded.emit()
            return
        super().mouseReleaseEvent(ev)
        self.viewport().setCursor(Qt.CursorShape.CrossCursor)
        if ev.button() == Qt.MouseButton.LeftButton and self._press is not None:
            moved = (ev.position() - self._press).manhattanLength()
            self._press = None
            if moved < 4 and self.has_image():
                inv, ok = self.viewportTransform().inverted()
                sp = inv.map(ev.position())
                x, y = sp.x() - 0.5, sp.y() - 0.5
                W, H = self.pix.pixmap().width(), self.pix.pixmap().height()
                if -0.5 <= x <= W - 0.5 and -0.5 <= y <= H - 0.5:
                    alt = bool(ev.modifiers() & Qt.KeyboardModifier.AltModifier)
                    self.clicked.emit(x, y, alt)


# ============================================================== export
# ============================================================== superposition
STACK_MAX = 1600


STACK_MODES = [("Assombrir (sujet sombre)", "min"), ("Éclaircir (sujet clair)", "max"),
               ("Moyenne", "mean")]


def compose_stack(jobs, adj, max_side=STACK_MAX, mode="min"):
    """Moyenne de toutes les photos alignées, dans l'espace de sortie, en basse résolution.
    jobs : [(chemin, (W, H), (s, ox, oy))]. Retourne (image BGR 8 bits, (ux0, uy0, c)) où
    un pixel i du canevas correspond à o = (i + 0.5) / c - 0.5 + ux0 en coordonnées de sortie.
    Les zones non couvertes par toutes les photos sont assombries et teintées de rouge."""
    ux0 = min(ox for _, _, (s, ox, oy) in jobs)
    uy0 = min(oy for _, _, (s, ox, oy) in jobs)
    ux1 = max(ox + s * W for _, (W, H), (s, ox, oy) in jobs)
    uy1 = max(oy + s * H for _, (W, H), (s, ox, oy) in jobs)
    c = min(1.0, max_side / max(ux1 - ux0, uy1 - uy0))
    cw, ch = max(1, int(math.ceil((ux1 - ux0) * c))), max(1, int(math.ceil((uy1 - uy0) * c)))
    acc = np.zeros((ch, cw, 3), np.float32)
    if mode == "min":
        ext = np.full((ch, cw, 3), 255, np.uint8)
    elif mode == "max":
        ext = np.zeros((ch, cw, 3), np.uint8)
    cnt = np.zeros((ch, cw), np.float32)
    for src, (W, H), (s, ox, oy) in jobs:
        q = s * c
        k = next((k for k in (8, 4, 2) if k * q <= 1.2), 1)
        flag = {8: cv2.IMREAD_REDUCED_COLOR_8, 4: cv2.IMREAD_REDUCED_COLOR_4,
                2: cv2.IMREAD_REDUCED_COLOR_2, 1: cv2.IMREAD_COLOR}[k]
        img = cv2.imread(str(src), flag)
        if img is None:
            continue
        kx, ky = W / img.shape[1], H / img.shape[0]
        # p' (réduit) -> p = (p' + .5) k - .5 -> o = s p + ox -> canevas = (o - u0 + .5) c - .5
        M = np.float32([[q * kx, 0, q * (0.5 * kx - 0.5) + c * (ox - ux0) + 0.5 * c - 0.5],
                        [0, q * ky, q * (0.5 * ky - 0.5) + c * (oy - uy0) + 0.5 * c - 0.5]])
        w = cv2.warpAffine(img, M, (cw, ch), flags=cv2.INTER_AREA if q * k < 1 else cv2.INTER_LINEAR,
                           borderMode=cv2.BORDER_CONSTANT, borderValue=0)
        m = cv2.warpAffine(np.ones(img.shape[:2], np.float32), M, (cw, ch), flags=cv2.INTER_LINEAR,
                           borderMode=cv2.BORDER_CONSTANT, borderValue=0)
        if mode == "mean":
            acc += w.astype(np.float32) * m[..., None]
        else:
            inside = (m > 0.99)[..., None]
            cand = np.where(inside, w, ext)
            ext = np.minimum(ext, cand) if mode == "min" else np.maximum(ext, cand)
        cnt += m
    n = len(jobs)
    out = acc / np.maximum(cnt, 1e-6)[..., None] if mode == "mean" else ext.astype(np.float32)
    out = apply_adjust(np.clip(out, 0, 255).astype(np.uint8), adj).astype(np.float32)
    partial = (cnt < n - 0.01)[..., None]
    tint = out * 0.45 + np.float32([0, 0, 90])          # zone où il manque au moins une photo
    out = np.where(partial, tint, out)
    out[cnt < 0.01] = (20, 20, 20)
    return np.clip(out, 0, 255).astype(np.uint8), (ux0, uy0, c)


# ============================================================== vidéo
VIDEO_RES = [("HD 1080p", (1920, 1080)), ("QHD 1440p", (2560, 1440)), ("4K UHD", (3840, 2160))]
VIDEO_FPS = ["15", "18", "20", "23.976", "24", "25", "29.97", "30", "50", "60"]


def parse_fps(text):
    """'24', '29.97', '30000/1001' ou '24 i/s' -> Fraction (1 à 120), sinon None."""
    from fractions import Fraction
    t = str(text).lower().replace("i/s", "").replace("fps", "").replace(",", ".").strip()
    try:
        f = Fraction(t)
    except (ValueError, ZeroDivisionError):
        return None
    special = {Fraction("23.976"): Fraction(24000, 1001), Fraction("29.97"): Fraction(30000, 1001),
               Fraction("59.94"): Fraction(60000, 1001)}
    f = special.get(f, f.limit_denominator(1001))
    return f if 1 <= f <= 120 else None
AUDIO_SR = 48000


def find_ffmpeg():
    try:
        import imageio_ffmpeg
        return imageio_ffmpeg.get_ffmpeg_exe()
    except Exception:
        pass
    return shutil.which("ffmpeg") or next(
        (p for p in ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg") if Path(p).exists()), None)


# Fin d'un déclenchement mécanique de Sony A7 II (freesound_community, son n° 43621) :
# « tchk » du 1er rideau puis « clac » du 2e rideau, 127 ms, WAV mono 16 bits 48 kHz.
SONY_A7II_WAV = """
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
CADL/9T/zv+j/3L/TP9D/3b/y//n/7D/kP/k/3MAxgC/AKcArgCyAHYA8f9s/03/nv/v/9j/iP+C/9//OQBZAHgAtAC0AEcA2P/U
/wgAAwDG/6v/2/82AJIAxQCqAE8ABQD1/9D/T//D/sr+bv/2/77/Cv+9/lz/fQA6AQ4BUgDS/+j/IwAFALT/sP8JAEEABgCe/4v/
9/+JAMYAhAAgABsAfQC7AFoAlv8Y/zb/m//N/7//zv8wALAA7ADKAIEATQApAPn/0//S/87/p/+Q/73/CwA5ACcA+f/y/ycAaACf
AO0AJQHVAPf/J/8D/43/TQDEAKYAIwDb/ysAqwDWAMIAkgAXAG7/Gv9X/+//dQCBAB0A7P87AKUAtwBvAPT/bf8j/0n/rP/g/8v/
r//D/xAAaABlAAwA6v8jAEAACQDL/8H/0f+2/5T/3f9wALAAlQCOALMAzQCxAIIAnwDQAHAA0//M/xcAEAD2/ycAVgA0ABMAbwDl
AIQAbf+e/nH+2f6x/xkAsv+I//H/9f+m/9H/IQAiACsAOADr/6P/yP/Q/2z/c/8/AN8A7QDxAM4AaABwALkAngCjAJ8AfP9l/h//
SQBxAKIAAwF+AL//3P9tAAkBYwGgACX/tv6l/4MAhwAAADL/rf4//ywAy//G/hb/FgAbALb/wf/m/2QAQwFFAXYAXQAAAS0BoADc
/1X/Q/+b/zUAawC1/xf/ov9TAEQAJQA0ABEA9f/T/3v/nf92ACQBEQF/ABQADgDs/8L/RQCsAPr/SP+J/ykA1gANAR4ASP+y/wsA
tP/a/1EALgD8/z4ASQC//0//kP8IAEMAqgABAZYAVQDKAIoA+f/dAKQBQADO/mb/jQC7AGQARgA5ANX/cf/x/lb9Ovxp/pwBDQIW
Af7/Xf0X/GX/QQI+AMj9sf1S/Jb6kv6RBpoJbgWvAIwAiARwCIEH2QKu/7H8bfdb9hz7LPz2+Un94wBQ/eH6x/6UATsBwwBK/iL7
q/wwAeYDOgU8BswE/P9p+zH76v2UAMYDeQXGAeH8Xfyy/X3+6P67/CP6sPsd/pT/YQQzCc8HuwOHAOT8F/pv+Jf35PvJA2kGZgO+
ANEAvgWJDQAP3QcD/8j4uvfO/CcC7AIjAQz/ov1g/XT8Bvvj+qn6dfrT+2D8E/2RAZwEkgI+AoADGQDB/Gj+nADDAm0GAwj5B/AH
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
aP9x/4r/JP+k/iH/LACOAGUAVwBbAH4AEQHGAdEB1gCI/yL/yv9zAKQAfwDf/w7/y/4D/1H/qv+d/w//Hv8IAI0AUAADAMX/kf+q
/+b/IQBRAPX/Tf+C/0sAZgAWAA4A2/96/8j/lwANARQBywAMADD/9v6j/9sAwgGBAVMAaf9b/8D/OwCkAHoAov/q/vL+i/89AHIA
5v9R/1n/mv/o/4QA+ADUAKUAmwBYACYAUQB2AI8ApwA8AHv/Mv84/wX/3/7o/sz+s/73/pH/cQBlAfUBJQJGAhICQgFXAN3/yf8D
AGYAaADh/37/jf+N/3//5v9uAI4AmgC0AF0A9P9BANkA0gA4AKf/g//K/xUASACjANYAbAD4/yIAegBZAOj/j/+U//X/OgAMAML/
qv+R/1D/IP9B/8b/TABZAE8A5gCDAS4BgQBuAHQAJAAFAPD/T//H/vT+Of87/zr///6q/hz/PgDwAN0AoQCGAF8AKgAxAG8AWAC+
/zL/G/9v/xUAnQCFADcANwAUAH3/Jf+w/64ACwFnAL3/3f8yAB4A6P++/5z/vf8XAG8A3wA7ARkB1wD8ACoB3wBBALf/cv9G/wz/
Lv/M/ygA/v/T/8b/w/8HAG4ApQDdAAYBrQAZAOT/CwBLAF0AGQDc//P////j/wcAWABjACQAz/+X/7L/8v8BAAoAYgD3AGEBRAHW
AKoAoAAxAL7/x/+4/0D/Qv/2/1QA/v+k/6r/6/8+AFoAHgD8/ywAOAD2/97/AADv/7n/w/8HADMAGgDQ/4r/gv+q/7n/gv9I/4n/
RwDZAM4AWwDZ/5P/zf9dAKQAPgCS/3b/GQCrAJgASAAcAPD/4/8kAEcA7v9s/z//fv/f/xMAGgAHAMD/bP9v/6z/0f/+/zUAGgDH
/73/FQCGAMcAxACyALIAhQApAPr/4/+b/3r/5/94AKsAiQA3ANL/sP/+/14AgQCDAH8AagBHACEA//8MAFEAkQDBAP0A+wByAM//
pf/g/w8ABQDF/0X/ov5a/tn+yf9OAB0A0v8TAMMAKwHqAFoA9//W//T/TwCgAHwA8/+N/6//HABIABcA+P8aADEABQDK/8z/EgBA
APz/lv+m/xMAWQBUACcA5P/X/z8AtwCqADAAtv9x/4P/7/9JADUA8v/W/8X/gv8r/wn/G/80/0H/Sf9f/6D/+/8uAE0AqgArAWcB
QgHUAEIA3P+7/7D/zv8VAO7/QP8L/8b/hgB/AAwA6P87AKsA6QDwAKEA3/8n/y7/wP/7/5P/EP8O/5L/IwB9ANIAKAEpAeIAxgDQ
AJUAKgD2/9v/p/+n/+3/9v+r/47/zv81AKYA/wAVAdwAVQCq/1P/gv/d/xMANgBXAFwANAAgAIsATgG9AZYBHAFxAKf/Ff8Z/6b/
OQBUABgA9//2/+r/2f/R////lAAuATIBxwBgAAEAr/+Q/4z/eP9v/6z/SAD2AC4B3gBxADQASgCZAJgADgCX/6z/BQA/ABUAWv99
/lr+G/8ZAMYAAwHhAKMAwQBFAZIBLgFIAFr/0P7h/kz/sv/Y/33/z/62/nP/GQAxADwAdACnAMAAngBNAC8AIwCx/xz/A/9m//n/
dQCLADQA5/8TAI4AqgAjAJT/fP+h/8n/EABYAFgAHAD3/x4AVAA8ABwAaADEALkAiQBdABUA/f84ADsAxf9Z/3X/CwC4ABIB+ACG
AO//mv/P/0kAtgAAAfIAXACo/4H/CwC4AOIAcwDd/5b/2v9xANMAvwBqABQABQBXAJsAcgAqAB0APQBjAE8A2v9p/2z/1v96ABQB
LgHeAKkAhAApAM7/s/+7/7z/qf+S/4//m//M/zoAigBoADMAPgBZAHIAkwCbAI4AZQDv/3X/g//Z/9b/mP+E/4P/g//I/1YAmgA7
ALr/nP+t/8f/JACVAJAAJADO/8H/5f8MAC4AWgBkABUAt/+h/6T/if+C/7D/4v/1/xAAZQDIAP0ADAHuAG0Azv+g/+L/IgAbAMv/
fv+K/7n/tv+2//T/WQDSADUBLAHJAG8AMwAEAPH//P8IAPv/5P/8/y4ANAAwAEgAHQCv/7L/NgByACgAzf+Y/3r/fv/H/08AwADF
AIcAZQByAJYArwCRAEAA/v/x//n/7f/c/wcAdQC0AFYAh//n/uL+Qv+S/8n/CwA5ADcAOwBdAIQAlQCFAHkApQDGAHUA+f/T/+r/
yv9U/9L+rv4O/67/OQBzAD4A4//U/wgAHwABAOD/4P8QAGsAvwDOAI0ASwAyAPz/l/9z/7X/7v/h/7L/j/+e/8//zf+c/6j/CABz
ALAArABpACcAJABDACsAwf9Z/2f/1/8lAB8AAwD0/9L/uf///4MAugB8ADsASwB3AF0A5/9i/zf/Z/+j/+H/RwC6APIA1QB2AAUA
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
+v/9//3/AAAEAAUADgAYAA4A9v/k/+X/9f8CABEAIgAbAPz/7/8IACMAKAAWAPn/6f/6/xUAJwArABsACgAYADYAPQAqABAA+//o
/9r/0P/J/8z/7P8fADMAEwDt/+X/9P8DAAAA6//b/9r/3//v/wUABADh/8X/2v8PADMAMAAeAAgA8f/m/+H/0P/B/8X/yP/K/+f/
DAASAP7/6P/s/w0AJwAUAO3/2f/W/97/7v/2/+z/4//r/wAADwAOAAUABAAFAPT/1P/B/8v/4P/v//3/CAAQABkAIAAeAB0AJwAp
ABgAAwDw/+j/9v8KAA4ABgD6/+r/6P/3/wQACwAGAPD/4P/z/xMAGQABAOf/4//1/wkAGQAeABIAAQABAAgAAQDx/+X/6P/w/+r/
3f/h//X/CAAYAB8AEAD8//7/FAAmACUAGwASAAwADQAVABEA+v/h/9j/3f/r//v//P/0//j/BgAUACIAKgAdAAMA/f8TACcAHAAA
AOv/5P/v/wsAGgALAPL/4P/b/+L/8f8BABIAGQAJAPn//v8NABUAEgAEAPL/6//y//3/CQAQAAsAAwAEAAsAEQANAP7/7f/p/+7/
7P/p/+//9//1//H/6//j/+n/AQAQAAkA/f/x/+r/9/8MAAwAAAD9//v/9f/2//T/6v/o//H/8v/m/9v/1v/Y/+H/8v8GABEADgAH
AAUABwAEAP3/8//y//f/+P/y/+7/9/8FABEAFgAQAAIA/f8LAB4AGAAAAO7/7f/3/wMACgABAPX/8P/4/wwAJAAtABsA/f/n/+T/
7f/3//3//P/6//r/9f/t//D/+P/8////BAAEAAYAEQAdABwAEgALAAkABQD7//P/9P/0//L/9P/2//L/9/8GAA0ABQAAAAAAAAAA
AAQACQAGAP7/8//t/+3/7v/t/+z/7P/v//z/DQAVABIADQAIAAEABAAIAAIA+v/2//P/9P8FAB4AJgAYAP//6//t/wEAEwAVAAcA
9f/u//n/CAASABQADgAHAAgADQAHAPv/9//5//n/+v8AAAUAAQAAAAsAGAAaABMACgABAPz/+/8BAAgACAAAAPb/9P/6/wEACAAM
AA8AEwAZABsAFgAKAP///f8HABcAGAAKAP////8IABIAEwALAAEA+P/x//X/AwAPABAACQD9//L/9f8CAAwADQAHAP3/8//z//X/
8//2///////6/wAADgAVABAABgACAAUABgAAAPj/9//+/wkADgALAAoADwARAAcA///9//z//f8AAP3/9v/1//r///8AAPv/9P/3
/wIACwANAAwACAACAP3/9//x//D/9P/3//r/AAAEAAMAAwAEAAEA+//5/wAAAwAAAP3/AAAKABQAFwASAAkABwAIAAUAAgAEAAcA
AgD+//3//v/6//n//f8EAAgABwAGAAUABgAIAAkACQAIAAYAAwABAAUACAAHAAcABwAFAAMABAAGAAUABgAFAAAA//8BAAQABgAJ
AAkAAwAAAAUADAAPAA0ABQD+//3/AAADAAgACgAHAAEA/f/6//v/AAAEAAUAAgAAAAAABAAFAAMAAgADAAAA/v/+//7//f/6//j/
+f///wEA/f/4//n/+v/6//3/AAAAAP///f/8//7/AAD7//H/6//y//z////8//f/9v/5////AwAFAAIA+//0//T/+f/8//v/+//8
//v/+P/7/wIABwAGAAIA///9/wAAAwAFAAUABQACAP7/+//9/wAAAQAAAP3/+v/6//3/AAAAAP3//P///wMACAAKAAYABQAHAAcA
BQACAAEAAAAAAP///v/+/////v/7//r/+//+/wEABAAFAAQAAQD/////AgAGAAgACAAHAAUAAwABAAAA/v/9//v//P///wEAAwAD
AAIAAgACAAEAAAAAAAAAAQAAAAAA/////wAAAwADAAMAAgAAAAAAAgAGAAUAAAD///7///8BAAQABAAAAAAAAAACAAEAAAAAAP//
AAAAAAAA/v/9////AAAAAP7//v8AAAEAAQACAAQABQAEAAIAAAAAAAEAAAAAAP///v/9//z//v8BAAQABQACAAAA/v///wAAAgAD
AAEAAAABAAEAAAAAAAAAAAABAAEAAAAAAAAAAwAEAAQAAwACAAEAAAD9//z//v8AAAEAAgABAAAAAQACAAMAAgABAAAAAQADAAQA
AwACAAAAAAABAAIAAAAAAAAAAQADAAMAAgAAAAAAAAABAAEAAAAAAAAAAAABAAIAAQABAAEAAgABAAEAAwADAAIAAAD+//3//v8A
AAEAAgABAAAAAAAAAAEAAgADAAIAAAD//wAAAgACAAIAAQABAAAAAAAAAAEAAQAAAAAAAAAAAAEAAgACAAEAAAAAAP//AAAAAAEA
AQAAAAAAAAAAAAEAAAD/////AAAAAAEAAQAAAAAAAAABAAEAAQAAAAAAAAD/////AAACAAIAAQAAAAAAAAAAAAEAAQABAAAAAAD/
////AAAAAAAAAAAAAAAAAAAAAAEAAgABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAQAAAAEAAQABAAAAAAAAAAEA
AQABAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAA=
"""


def sony_sound():
    import base64
    import io
    raw = base64.b64decode("".join(SONY_A7II_WAV.split()))
    with wave.open(io.BytesIO(raw)) as w:
        x = np.frombuffer(w.readframes(w.getnframes()), "<i2").astype(np.float32) / 32767
        sr = w.getframerate()
    if sr != AUDIO_SR:
        x = np.interp(np.arange(int(len(x) * AUDIO_SR / sr)) * sr / AUDIO_SR, np.arange(len(x)), x)
    return x.astype(np.float32)


SOUND_SOURCES = [("Sony A7 II (enregistrement)", "sony"), ("Fichier personnel…", "file")]


def load_sound(path, ffmpeg):
    """Décode n'importe quel fichier audio (wav, mp3, m4a…) en mono 48 kHz."""
    r = subprocess.run([ffmpeg, "-v", "error", "-i", str(path), "-f", "f32le", "-ac", "1",
                        "-ar", str(AUDIO_SR), "-"], capture_output=True)
    if r.returncode != 0 or not r.stdout:
        raise RuntimeError(f"Impossible de lire le son : {r.stderr.decode(errors='ignore')[:300]}")
    x = np.frombuffer(r.stdout, np.float32).copy()
    x = x[: int(2.0 * AUDIO_SR)]                      # 2 s max
    peak = np.abs(x).max()
    return x / peak * 0.9 if peak > 0 else x


def write_wav(path, x, sr=AUDIO_SR):
    pcm = (np.clip(x, -1, 1) * 32767).astype("<i2")
    with wave.open(str(path), "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(sr)
        w.writeframes(pcm.tobytes())


def build_audio(n_photos, total_frames, fps, sound, volume):
    n = int(math.ceil(total_frames / fps * AUDIO_SR))
    out = np.zeros(n, np.float32)
    snd = sound * volume
    for i in range(n_photos):
        a = int(round(i / fps * AUDIO_SR))
        b = min(n, a + len(snd))
        if a < n:
            out[a:b] += snd[: b - a]
    peak = np.abs(out).max()
    if peak > 0.98:                                   # rafale rapide : sons superposés
        out = np.tanh(out / peak * 1.5) / np.tanh(1.5) * 0.98
    return out


def video_size(w, h, box):
    f = min(box[0] / w, box[1] / h, 1.0)
    return max(2, int(w * f) // 2 * 2), max(2, int(h * f) // 2 * 2)


def render_video_frame(src, size, tfm, rect, vw, vh, adj):
    """Image alignée directement à la taille vidéo (réduction d'abord, pour la vitesse)."""
    s, ox, oy = tfm
    cx0, cy0, cx1, cy1 = rect
    W, H = size
    r = vw / (cx1 - cx0)                     # échelle sortie -> vidéo
    q = s * r                                # échelle source -> vidéo
    if q < 1:
        k = next((k for k in (8, 4, 2) if k * q <= 1), 1)
        flag = {8: cv2.IMREAD_REDUCED_COLOR_8, 4: cv2.IMREAD_REDUCED_COLOR_4,
                2: cv2.IMREAD_REDUCED_COLOR_2, 1: cv2.IMREAD_COLOR}[k]
        img = cv2.imread(str(src), flag)
        if img is None:
            raise RuntimeError(f"Impossible de lire {src.name}")
        if abs(img.shape[1] * k - W) > k or abs(img.shape[0] * k - H) > k:
            raise RuntimeError(f"{src.name} a changé de taille depuis le pointage.")
        sw, sh = max(1, round(W * q)), max(1, round(H * q))
        img = cv2.resize(img, (sw, sh), interpolation=cv2.INTER_AREA)
        qx, qy = sw / W, sh / H
        # centre de pixel : p = (p' + 0.5) / q - 0.5
        M = np.float32([[1, 0, r * (ox - cx0) + 0.5 * r - 0.5 * qx],
                        [0, 1, r * (oy - cy0) + 0.5 * r - 0.5 * qy]])
        M[0, 0] = q / qx
        M[1, 1] = q / qy
    else:
        img = to8(load_image(src))
        M = np.float32([[q, 0, r * (ox - cx0) + 0.5 * r - 0.5],
                        [0, q, r * (oy - cy0) + 0.5 * r - 0.5]])
    out = cv2.warpAffine(img, M, (vw, vh), flags=cv2.INTER_LANCZOS4,
                         borderMode=cv2.BORDER_CONSTANT, borderValue=0)
    return apply_adjust(out, adj)


class VideoWorker(QThread):
    progress = Signal(int, str)
    done = Signal(str)
    failed = Signal(str)

    def __init__(self, jobs, rect, dst, ffmpeg, fps, hold_s, box, adj, sound, volume):
        super().__init__()
        self.jobs, self.rect, self.dst, self.ffmpeg = jobs, rect, Path(dst), ffmpeg
        self.fps, self.hold_s, self.box = fps, hold_s, box
        self.adj, self.sound, self.volume = dict(adj or {}), sound, volume
        self.cancel = False

    def run(self):
        proc, wav = None, None
        try:
            cx0, cy0, cx1, cy1 = self.rect
            vw, vh = video_size(cx1 - cx0, cy1 - cy0, self.box)
            n = len(self.jobs)
            fps = float(self.fps)
            hold = int(round(self.hold_s * fps))
            total = n + hold
            cmd = [self.ffmpeg, "-y", "-v", "error",
                   "-f", "rawvideo", "-pix_fmt", "bgr24", "-s", f"{vw}x{vh}",
                   "-r", f"{self.fps.numerator}/{self.fps.denominator}", "-i", "-"]
            if self.sound is not None:
                fd, wav = tempfile.mkstemp(suffix=".wav")
                os.close(fd)
                write_wav(wav, build_audio(n, total, fps, self.sound, self.volume))
                cmd += ["-i", wav, "-c:a", "aac", "-b:a", "192k"]
            cmd += ["-vf", "scale=out_color_matrix=bt709:out_range=tv",
                    "-c:v", "libx264", "-preset", "medium", "-crf", "17", "-pix_fmt", "yuv420p",
                    "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709",
                    "-movflags", "+faststart", str(self.dst)]
            proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
            frame = None
            for i, (src, size, tfm) in enumerate(self.jobs):
                if self.cancel:
                    raise InterruptedError
                frame = render_video_frame(src, size, tfm, self.rect, vw, vh, self.adj)
                data = np.ascontiguousarray(frame).tobytes()
                proc.stdin.write(data)
                self.progress.emit(i + 1, src.name)
            for _ in range(hold):
                proc.stdin.write(data)
            proc.stdin.close()
            err = proc.stderr.read().decode(errors="ignore")
            if proc.wait() != 0:
                raise RuntimeError(f"ffmpeg : {err[-600:]}")
            self.done.emit(str(self.dst))
        except InterruptedError:
            self._abort(proc)
            self.failed.emit("")
        except BrokenPipeError:
            err = proc.stderr.read().decode(errors="ignore") if proc else ""
            self._abort(proc)
            self.failed.emit(f"ffmpeg s'est arrêté : {err[-600:]}")
        except Exception as e:
            self._abort(proc)
            self.failed.emit(str(e))
        finally:
            if wav:
                try:
                    os.remove(wav)
                except OSError:
                    pass

    def _abort(self, proc):
        if proc and proc.poll() is None:
            proc.kill()
            proc.wait()
        try:
            self.dst.unlink()
        except OSError:
            pass


class ExportWorker(QThread):
    progress = Signal(int, str)
    done = Signal(int)
    failed = Signal(str)

    def __init__(self, jobs, rect, out_dir, fmt, quality, exiftool, adj=None):
        super().__init__()
        self.adj = dict(adj or {})
        self.jobs, self.rect, self.out_dir = jobs, rect, out_dir
        self.fmt, self.quality, self.exiftool = fmt, quality, exiftool
        self.cancel = False

    def run(self):
        try:
            cx0, cy0, cx1, cy1 = self.rect
            w, h = cx1 - cx0, cy1 - cy0
            n = 0
            for src, size, (s, ox, oy) in self.jobs:
                if self.cancel:
                    break
                img = load_image(src)
                if (img.shape[1], img.shape[0]) != tuple(size):
                    raise RuntimeError(f"{src.name} a changé de taille depuis le pointage : "
                                       "efface ses points et reclique.")
                M = np.float32([[s, 0, ox - cx0], [0, s, oy - cy0]])
                out = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_LANCZOS4,
                                     borderMode=cv2.BORDER_CONSTANT, borderValue=0)
                out = apply_adjust(out, self.adj)
                ext = src.suffix if self.fmt == "orig" else ("." + self.fmt)
                params = []
                if ext.lower() in (".jpg", ".jpeg"):
                    out = to8(out)
                    params = [cv2.IMWRITE_JPEG_QUALITY, self.quality]
                dst = self.out_dir / (src.stem + ext)
                if not cv2.imwrite(str(dst), out, params):
                    raise RuntimeError(f"Échec d'écriture : {dst}")
                if self.exiftool:
                    subprocess.run([self.exiftool, "-q", "-q", "-tagsfromfile", str(src), "-all:all",
                                    "--Orientation", "-overwrite_original", str(dst)],
                                   capture_output=True)
                n += 1
                self.progress.emit(n, src.name)
            self.done.emit(n)
        except Exception as e:
            self.failed.emit(str(e))


class ThumbSignals(QObject):
    ready = Signal(str, str, QImage)
    rendered = Signal(str, str, QImage)     # nom, clé des réglages, image pleine résolution
    stack = Signal(str, QImage, object)     # clé, superposition, géométrie


# ============================================================== fenêtre
class MainWindow(QMainWindow):
    def __init__(self, folder=None):
        super().__init__()
        self.setWindowTitle(APP)
        self.resize(1500, 950)
        self.setAcceptDrops(True)
        self.sess = None
        self.cur = None
        self.cur_size = None
        self.out_dir = None
        self.worker = None
        self.ref_pix, self.ref_pix_name = None, None
        self.pool = ThreadPoolExecutor(max_workers=2)
        self.thumb_pool = ThreadPoolExecutor(max_workers=2)
        self.cache = {}
        self.tsig = ThumbSignals()
        self.tsig.ready.connect(self._on_thumb)
        self.tsig.rendered.connect(self._on_rendered)
        self.proxies = {}
        self.render_pool = ThreadPoolExecutor(max_workers=1)
        self.stack_pool = ThreadPoolExecutor(max_workers=1)
        self.tsig.stack.connect(self._on_stack)
        self._stack = None            # (clé, QPixmap, géométrie)
        self._stack_pending = None
        self._stack_active = False    # vrai pendant le réglage du cadre

        self.list = QListWidget()
        self.list.setIconSize(QSize(96, 64))
        self.list.setUniformItemSizes(True)
        self.list.currentRowChanged.connect(self._on_row)

        self.view = ImageView()
        self.view.clicked.connect(self._on_click)
        self.view.zoomChanged.connect(self._update_status)
        self.view.frameDragged.connect(self._on_frame_drag)
        self.view.frameDragEnded.connect(self._save)
        self.view.frameDragStarted.connect(lambda: self._stack_hold(True))
        self.view.frameDragEnded.connect(lambda: self._stack_hold(False))
        self.frame_center = None     # centre du cadre (coordonnées de sortie) ; None = auto

        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setWidget(self._build_panel())
        scroll.setMinimumWidth(370)
        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)

        split = QSplitter()
        split.addWidget(self.list)
        split.addWidget(self.view)
        split.addWidget(scroll)
        split.setStretchFactor(1, 1)
        split.setSizes([240, 950, 320])
        self.setCentralWidget(split)
        self._shortcuts()
        self._update_enabled()
        self.statusBar().showMessage("Ouvre un dossier de photos (ou glisse-le dans la fenêtre).")
        if folder:
            p = Path(folder).expanduser()
            if p.is_file():
                QTimer.singleShot(0, lambda: self.open_project(p))
            else:
                QTimer.singleShot(0, lambda: self.open_folder(p))

    # ---------------------------------------------------------- panneau
    def _build_panel(self):
        w = QWidget()
        lay = QVBoxLayout(w)

        row = QHBoxLayout()
        b = QPushButton("Ouvrir un dossier…")
        b.setToolTip("Si le dossier contient déjà un projet, le travail reprend automatiquement.")
        b.clicked.connect(lambda: self.open_folder())
        row.addWidget(b)
        b = QPushButton("Ouvrir un projet…")
        b.clicked.connect(lambda: self.open_project())
        row.addWidget(b)
        lay.addLayout(row)
        self.lbl_folder = QLabel("—")
        self.lbl_folder.setWordWrap(True)
        self.lbl_folder.setStyleSheet("color: gray;")
        lay.addWidget(self.lbl_folder)
        row = QHBoxLayout()
        self.lbl_project = QLabel()
        self.lbl_project.setWordWrap(True)
        self.lbl_project.setStyleSheet("color: gray; font-size: 11px;")
        row.addWidget(self.lbl_project, 1)
        self.btn_save_as = QPushButton("Enregistrer sous…")
        self.btn_save_as.setToolTip("Enregistrer le projet (points, cadrage, réglages, vidéo) "
                                    "dans un autre fichier")
        self.btn_save_as.clicked.connect(self.save_project_as)
        self.btn_save_as.setEnabled(False)
        row.addWidget(self.btn_save_as)
        lay.addLayout(row)

        g = QGroupBox("Image courante")
        v = QVBoxLayout(g)
        self.lbl_hint = QLabel()
        self.lbl_hint.setWordWrap(True)
        self.lbl_hint.setMinimumHeight(48)
        v.addWidget(self.lbl_hint)
        self.btn_ref = QPushButton("★ Définir comme référence")
        self.btn_ref.clicked.connect(self.set_reference)
        self.btn_skip = QPushButton("Ignorer cette image")
        self.btn_skip.clicked.connect(self.toggle_skip)
        self.btn_clear = QPushButton("Effacer les points")
        self.btn_clear.clicked.connect(self.clear_points)
        self.chk_auto = QCheckBox("Image suivante après pointage")
        self.chk_auto.setChecked(True)
        for x in (self.btn_ref, self.btn_skip, self.btn_clear, self.chk_auto):
            v.addWidget(x)
        lay.addWidget(g)

        g = QGroupBox("Alignement")
        f = QFormLayout(g)
        self.chk_scale = QCheckBox("Normaliser la taille (2e repère)")
        self.chk_scale.setToolTip("Deux clics par image : l'œil puis un 2e repère fixe "
                                  "(pointe du bec…). Compense un sujet qui s'approche ou s'éloigne.")
        f.addRow(self.chk_scale)
        self.cmb_target = QComboBox()
        for label, _ in TARGETS:
            self.cmb_target.addItem(label)
        f.addRow("Position de l'œil", self.cmb_target)
        self.sp_tx, self.sp_ty = QDoubleSpinBox(), QDoubleSpinBox()
        for sp in (self.sp_tx, self.sp_ty):
            sp.setRange(0, 100)
            sp.setDecimals(1)
            sp.setSuffix(" %")
            sp.setValue(50)
        row = QHBoxLayout()
        row.addWidget(QLabel("X"))
        row.addWidget(self.sp_tx)
        row.addWidget(QLabel("Y"))
        row.addWidget(self.sp_ty)
        f.addRow(row)
        lay.addWidget(g)

        g = QGroupBox("Cadrage")
        f = QFormLayout(g)
        self.chk_crop = QCheckBox("Pas de bords noirs (zone commune)")
        self.chk_crop.setToolTip("Aucun bord noir : ne garde que la zone couverte par toutes les images.")
        self.chk_crop.setChecked(True)
        f.addRow(self.chk_crop)
        self.cmb_ratio = QComboBox()
        for label, _ in RATIOS:
            self.cmb_ratio.addItem(label)
        f.addRow("Ratio", self.cmb_ratio)
        self.sl_size = QSlider(Qt.Orientation.Horizontal)
        self.sl_size.setRange(20, 100)
        self.sl_size.setValue(100)
        self.sl_size.setToolTip("Taille du cadre par rapport au plus grand cadre possible.")
        self.lbl_size = QLabel("100 %")
        self.lbl_size.setMinimumWidth(40)
        row = QHBoxLayout()
        row.addWidget(self.sl_size)
        row.addWidget(self.lbl_size)
        f.addRow("Taille", row)
        self.sl_size.sliderPressed.connect(lambda: self._stack_hold(True))
        self.sl_size.sliderReleased.connect(lambda: self._stack_hold(False))
        self.btn_recenter = QPushButton("Recentrer le cadre")
        self.btn_recenter.clicked.connect(self.recenter_frame)
        f.addRow(self.btn_recenter)
        lbl = QLabel("⇧ + glisser dans l'image : déplacer le cadre")
        lbl.setStyleSheet("color: gray; font-size: 11px;")
        f.addRow(lbl)
        self.chk_frame = QCheckBox("Afficher le cadre final")
        self.chk_frame.setChecked(True)
        f.addRow(self.chk_frame)
        self.chk_stack_drag = QCheckBox("Superposer toutes les photos pendant le réglage")
        self.chk_stack_drag.setChecked(True)
        self.chk_stack_drag.setToolTip("Pendant le déplacement ou le redimensionnement du cadre, affiche la "
                                       "moyenne de toutes les photos alignées :\non voit si le sujet "
                                       "(ailes, queue…) sort du cadre sur l'une d'elles.\n"
                                       "Zones rougeâtres : non couvertes par toutes les photos.")
        f.addRow(self.chk_stack_drag)
        self.chk_stack_always = QCheckBox("Superposition permanente (T)")
        f.addRow(self.chk_stack_always)
        self.cmb_stack = QComboBox()
        for label, _ in STACK_MODES:
            self.cmb_stack.addItem(label)
        self.cmb_stack.setToolTip("Assombrir : garde le plus sombre de toutes les photos, idéal pour un "
                                  "oiseau sur ciel clair.\nÉclaircir : l'inverse.\n"
                                  "Moyenne : fondu de toutes les photos.")
        f.addRow("Fusion", self.cmb_stack)
        self.lbl_stack = QLabel()
        self.lbl_stack.setStyleSheet("color: gray; font-size: 11px;")
        f.addRow(self.lbl_stack)
        lay.addWidget(g)

        g = QGroupBox("Réglages (toute la série)")
        f = QFormLayout(g)
        self.adj_sliders, self.adj_labels = {}, {}
        for key, label, lo, hi, div, fmt in ADJ_SPECS:
            sl = QSlider(Qt.Orientation.Horizontal)
            sl.setRange(lo, hi)
            sl.setValue(0)
            sl.setToolTip("Double-clic sur le libellé : remettre à zéro")
            lb = QLabel()
            lb.setMinimumWidth(62)
            lb.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
            name = QLabel(label)
            name.mouseDoubleClickEvent = lambda ev, s_=sl: s_.setValue(0)
            row = QHBoxLayout()
            row.addWidget(sl)
            row.addWidget(lb)
            f.addRow(name, row)
            sl.valueChanged.connect(self._adj_changed)
            sl.sliderReleased.connect(self._adj_changed)
            self.adj_sliders[key], self.adj_labels[key] = sl, lb
        row = QHBoxLayout()
        self.chk_before = QCheckBox("Avant (A)")
        self.chk_before.setToolTip("Afficher l'image sans réglages pour comparer")
        self.chk_before.toggled.connect(self._adj_changed)
        b = QPushButton("Réinitialiser")
        b.clicked.connect(self.reset_adjust)
        row.addWidget(self.chk_before)
        row.addStretch(1)
        row.addWidget(b)
        f.addRow(row)
        lay.addWidget(g)
        self._adj_timer = QTimer(self)
        self._adj_timer.setSingleShot(True)
        self._adj_timer.setInterval(60)
        self._adj_timer.timeout.connect(self._render_adjust)
        self._update_adj_labels()

        g = QGroupBox("Contrôle")
        f = QFormLayout(g)
        self.chk_onion = QCheckBox("Superposer la référence alignée")
        f.addRow(self.chk_onion)
        self.sl_onion = QSlider(Qt.Orientation.Horizontal)
        self.sl_onion.setRange(5, 95)
        self.sl_onion.setValue(50)
        f.addRow("Opacité", self.sl_onion)
        lay.addWidget(g)

        g = QGroupBox("Export")
        f = QFormLayout(g)
        b = QPushButton("Dossier de sortie…")
        b.clicked.connect(self.choose_output)
        f.addRow(b)
        self.lbl_out = QLabel("—")
        self.lbl_out.setWordWrap(True)
        self.lbl_out.setStyleSheet("color: gray;")
        f.addRow(self.lbl_out)
        self.cmb_fmt = QComboBox()
        for label, key in FORMATS:
            self.cmb_fmt.addItem(label, key)
        f.addRow("Format", self.cmb_fmt)
        self.sp_q = QSpinBox()
        self.sp_q.setRange(50, 100)
        self.sp_q.setValue(95)
        f.addRow("Qualité JPEG", self.sp_q)
        self.exiftool = shutil.which("exiftool") or next(
            (p for p in ("/opt/homebrew/bin/exiftool", "/usr/local/bin/exiftool") if Path(p).exists()), None)
        self.chk_exif = QCheckBox("Recopier les métadonnées (exiftool)")
        self.chk_exif.setChecked(bool(self.exiftool))
        self.chk_exif.setEnabled(bool(self.exiftool))
        if not self.exiftool:
            self.chk_exif.setToolTip("exiftool introuvable : brew install exiftool")
        f.addRow(self.chk_exif)
        self.btn_export = QPushButton("Exporter la série alignée")
        self.btn_export.setMinimumHeight(34)
        self.btn_export.clicked.connect(self.export)
        f.addRow(self.btn_export)
        self.progress = QProgressBar()
        self.progress.hide()
        f.addRow(self.progress)
        lay.addWidget(g)

        g = QGroupBox("Vidéo")
        f = QFormLayout(g)
        self.cmb_fps = QComboBox()
        self.cmb_fps.setEditable(True)
        self.cmb_fps.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
        for v in VIDEO_FPS:
            self.cmb_fps.addItem(v)
        self.cmb_fps.setCurrentText("30")
        self.cmb_fps.setToolTip("Choisis ou tape n'importe quelle cadence de 1 à 120 i/s\n"
                                "(ex. 12, 23.976, 29.97, 48).")
        row = QHBoxLayout()
        row.addWidget(self.cmb_fps, 1)
        row.addWidget(QLabel("i/s"))
        f.addRow("Cadence", row)
        self.sp_hold = QDoubleSpinBox()
        self.sp_hold.setRange(0, 10)
        self.sp_hold.setSingleStep(0.5)
        self.sp_hold.setValue(1.0)
        self.sp_hold.setSuffix(" s")
        f.addRow("Pause finale", self.sp_hold)
        self.cmb_res = QComboBox()
        for label, box in VIDEO_RES:
            self.cmb_res.addItem(label, box)
        self.cmb_res.setCurrentIndex(2)
        f.addRow("Résolution", self.cmb_res)
        self.chk_sound = QCheckBox("Son de déclencheur à chaque photo")
        self.chk_sound.setChecked(True)
        f.addRow(self.chk_sound)
        self.sl_vol = QSlider(Qt.Orientation.Horizontal)
        self.sl_vol.setRange(5, 100)
        self.sl_vol.setValue(80)
        f.addRow("Volume", self.sl_vol)
        self.cmb_sound = QComboBox()
        for label, _ in SOUND_SOURCES:
            self.cmb_sound.addItem(label)
        self.cmb_sound.activated.connect(self._on_sound_source)
        f.addRow("Son", self.cmb_sound)
        row = QHBoxLayout()
        self.btn_sound = QPushButton("Choisir un fichier…")
        self.btn_sound.setToolTip("Utilise un enregistrement (wav, mp3, m4a…), par exemple\n"
                                  "le déclencheur de ton propre boîtier.")
        self.btn_sound.clicked.connect(self.choose_sound)
        self.btn_play = QPushButton("▶ Écouter")
        self.btn_play.clicked.connect(self.play_sound)
        row.addWidget(self.btn_sound)
        row.addWidget(self.btn_play)
        f.addRow(row)
        self.lbl_sound = QLabel()
        self.lbl_sound.setWordWrap(True)
        self.lbl_sound.setStyleSheet("color: gray; font-size: 11px;")
        f.addRow(self.lbl_sound)
        self.lbl_video = QLabel()
        self.lbl_video.setWordWrap(True)
        f.addRow(self.lbl_video)
        self.btn_video = QPushButton("Exporter la vidéo…")
        self.btn_video.setMinimumHeight(34)
        self.btn_video.clicked.connect(self.export_video)
        f.addRow(self.btn_video)
        self.vprogress = QProgressBar()
        self.vprogress.hide()
        f.addRow(self.vprogress)
        lay.addWidget(g)
        self.ffmpeg = find_ffmpeg()
        self.sound_file = None
        self._sound_cache = None
        self._player = None
        if not self.ffmpeg:
            self.btn_video.setToolTip("ffmpeg introuvable : python3 -m pip install imageio-ffmpeg")
        self._set_sound_file(None, save=False, source="sony")

        self.lbl_summary = QLabel()
        self.lbl_summary.setWordWrap(True)
        lay.addWidget(self.lbl_summary)

        help_ = QLabel(
            "<b>Clic</b> : placer l'œil (puis le 2e repère)<br>"
            "<b>⌥ + clic</b> : placer / déplacer le 2e repère<br>"
            "<b>Molette, pincement, ⌘ + défilement</b> : zoom<br>"
            "<b>Glisser</b> : déplacer la vue · <b>⇧ + glisser</b> : déplacer le cadre<br>"
            "<b>→ / Espace</b> : suivante · <b>←</b> : précédente<br>"
            "<b>F</b> : ajuster · <b>Z</b> : 100 % · <b>S</b> : ignorer<br>"
            "<b>R</b> : référence · <b>O</b> : superposition · <b>⌫</b> : effacer<br>"
            "<b>A</b> : avant / après réglages · <b>T</b> : superposition de toutes les photos")
        help_.setWordWrap(True)
        help_.setStyleSheet("color: gray; font-size: 11px;")
        lay.addWidget(help_)
        lay.addStretch(1)

        for sig in (self.chk_scale.toggled, self.cmb_target.currentIndexChanged,
                    self.sp_tx.valueChanged, self.sp_ty.valueChanged, self.chk_crop.toggled,
                    self.cmb_ratio.currentIndexChanged, self.chk_frame.toggled,
                    self.sl_size.valueChanged, self.chk_stack_always.toggled,
                    self.cmb_stack.currentIndexChanged,
                    self.chk_onion.toggled, self.sl_onion.valueChanged,
                    self.cmb_fmt.currentIndexChanged, self.sp_q.valueChanged,
                    self.cmb_fps.currentTextChanged,
                    self.sp_hold.valueChanged, self.cmb_res.currentIndexChanged,
                    self.chk_sound.toggled, self.sl_vol.valueChanged):
            sig.connect(self._options_changed)
        return w

    def _shortcuts(self):
        def sc(keys, fn):
            for k in keys:
                QShortcut(QKeySequence(k), self).activated.connect(fn)
        sc(["Right", "Space"], lambda: self._step(1))
        sc(["Left"], lambda: self._step(-1))
        sc(["S"], self.toggle_skip)
        sc(["R"], self.set_reference)
        sc(["Backspace", "Delete"], self.clear_points)
        sc(["F"], self.view.fit)
        sc(["Z"], self.view.actual_size)
        sc(["O"], lambda: self.chk_onion.toggle())
        sc(["A"], lambda: self.chk_before.toggle())
        sc(["T"], lambda: self.chk_stack_always.toggle())
        sc(["+", "="], lambda: self.view.zoom_by(1.5))
        sc(["-"], lambda: self.view.zoom_by(1 / 1.5))
        sc(["Ctrl+O"], lambda: self.open_folder())
        sc(["Ctrl+E"], self.export)

    # ---------------------------------------------------------- réglages
    def _n_pts(self):
        return 2 if self.chk_scale.isChecked() else 1

    def _target(self):
        t = TARGETS[self.cmb_target.currentIndex()][1]
        if t == "custom":
            return (self.sp_tx.value() / 100, self.sp_ty.value() / 100)
        return t

    def _settings(self):
        return {"scale": self.chk_scale.isChecked(), "target": self.cmb_target.currentIndex(),
                "tx": self.sp_tx.value(), "ty": self.sp_ty.value(),
                "crop": self.chk_crop.isChecked(), "ratio": self.cmb_ratio.currentIndex(),
                "size": self.sl_size.value(), "center": self.frame_center,
                "stack_mode": self.cmb_stack.currentIndex(), "stack_drag": self.chk_stack_drag.isChecked(), "adjust": self._adj(),
                "video": {"fps": self.cmb_fps.currentText().strip(),
                          "hold": self.sp_hold.value(), "res": self.cmb_res.currentIndex(),
                          "sound": self.chk_sound.isChecked(), "vol": self.sl_vol.value(),
                          "file": str(self.sound_file) if self.sound_file else None,
                          "source": self._sound_source()},
                "fmt": self.cmb_fmt.currentIndex(), "quality": self.sp_q.value(),
                "out": str(self.out_dir) if self.out_dir else None}

    def _apply_settings(self, s):
        widgets = (self.chk_scale, self.cmb_target, self.sp_tx, self.sp_ty, self.chk_crop,
                   self.cmb_ratio, self.cmb_fmt, self.sp_q, self.sl_size, self.cmb_fps,
                   self.sp_hold, self.cmb_res, self.chk_sound, self.sl_vol,
                   self.cmb_stack, self.chk_stack_drag)
        for wdg in widgets:
            wdg.blockSignals(True)
        try:
            self.chk_scale.setChecked(bool(s.get("scale", False)))
            self.cmb_target.setCurrentIndex(int(s.get("target", 0)))
            self.sp_tx.setValue(float(s.get("tx", 50)))
            self.sp_ty.setValue(float(s.get("ty", 50)))
            self.chk_crop.setChecked(bool(s.get("crop", True)))
            self.cmb_ratio.setCurrentIndex(int(s.get("ratio", 0)))
            self.cmb_fmt.setCurrentIndex(int(s.get("fmt", 0)))
            self.sp_q.setValue(int(s.get("quality", 95)))
            self.sl_size.setValue(int(s.get("size", 100)))
            self.cmb_stack.setCurrentIndex(int(s.get("stack_mode", 0)))
            self.chk_stack_drag.setChecked(bool(s.get("stack_drag", True)))
            self.lbl_size.setText(f"{self.sl_size.value()} %")
            v = s.get("video") or {}
            fv = v.get("fps", "30")
            if isinstance(fv, int) and not isinstance(fv, bool) and fv < 4:   # ancien format : index
                fv = ["24", "25", "30", "60"][fv]
            self.cmb_fps.setCurrentText(str(fv) if parse_fps(fv) else "30")
            self.sp_hold.setValue(float(v.get("hold", 1.0)))
            self.cmb_res.setCurrentIndex(int(v.get("res", 2)))
            self.chk_sound.setChecked(bool(v.get("sound", True)))
            self.sl_vol.setValue(int(v.get("vol", 80)))
            sf = v.get("file")
            src = v.get("source", "file" if sf else "sony")
            self._set_sound_file(Path(sf) if sf and Path(sf).exists() else None, save=False,
                                 source=src)
            a = s.get("adjust") or {}
            for key, sl in self.adj_sliders.items():
                div = next(d for k, _, _, _, d, _ in ADJ_SPECS if k == key)
                sl.blockSignals(True)
                sl.setValue(int(round(float(a.get(key, 0)) * div)))
                sl.blockSignals(False)
            self._update_adj_labels()
            c = s.get("center")
            self.frame_center = tuple(c) if c else None
        finally:
            for wdg in widgets:
                wdg.blockSignals(False)

    def _save(self):
        if not self.sess:
            return
        ok = self.sess.save(self._settings(), self.cur)
        self._update_project_label(ok)

    def _update_project_label(self, ok=True):
        if not self.sess:
            self.lbl_project.setText("")
            return
        name = self.sess.project.name
        if ok:
            extra = getattr(self, "_resume_msg", "")
            self.lbl_project.setText(f"Projet : <b>{name}</b>" + (f" — {extra}" if extra else "")
                                     + "<br>enregistré automatiquement")
            self.lbl_project.setToolTip(str(self.sess.project))
        else:
            self.lbl_project.setText(f"<span style='color:#e0a000'>⚠ Projet non enregistré "
                                     f"({self.sess.last_error}). Utilise « Enregistrer sous… ».</span>")

    def _layout(self):
        if not self.sess:
            return None, ""
        try:
            aspect = RATIOS[self.cmb_ratio.currentIndex()][1]
            return compute_layout(self.sess, self._n_pts(), self._target(),
                                  self.chk_crop.isChecked(), aspect,
                                  self.sl_size.value() / 100, self.frame_center), ""
        except LayoutError as e:
            return None, str(e)

    def _on_frame_drag(self, dx, dy):
        if not self.sess or not self.cur:
            return
        lay, _ = self._layout()
        if not lay or self.cur not in lay["tf"]:
            return
        s = lay["tf"][self.cur][0]
        cx0, cy0, cx1, cy1 = lay["rect"]
        # part du centre réel (déjà borné) pour que le cadre ne « colle » pas aux bords
        self.frame_center = ((cx0 + cx1) / 2 + s * dx, (cy0 + cy1) / 2 + s * dy)
        self._update_overlays()
        self._update_summary()

    def _adj(self):
        return {k: self.adj_sliders[k].value() / d for k, _, _, _, d, _ in ADJ_SPECS}

    def _view_adj(self):
        return {} if self.chk_before.isChecked() else self._adj()

    def _update_adj_labels(self):
        for key, label, lo, hi, div, fmt in ADJ_SPECS:
            v = self.adj_sliders[key].value()
            self.adj_labels[key].setText(fmt.format(v / div if div != 1 else v))

    def _adj_changed(self, *_):
        self._update_adj_labels()
        self._adj_timer.start()

    def _adj_key(self):
        return json.dumps(self._view_adj(), sort_keys=True)

    PROXY_MAX = 2000

    def _proxy(self, name):
        """Version réduite (≤ 2000 px) pour l'aperçu rapide des réglages."""
        if name not in self.proxies:
            img8, (W, H) = self._future(name).result()
            f = max(W, H) / self.PROXY_MAX
            if f > 1:
                p = cv2.resize(img8, (round(W / f), round(H / f)), interpolation=cv2.INTER_AREA)
            else:
                p = img8
            self.proxies[name] = (p, W / p.shape[1])
            keep = {self.cur, self.sess.ref if self.sess else None, name}
            for k in list(self.proxies):
                if k not in keep:
                    self.proxies.pop(k)
        return self.proxies[name]

    def _display_qimage(self, name, img8):
        """Sans réglage : image pleine résolution directe. Avec réglages : aperçu réduit
        immédiat, rendu pleine résolution lancé en arrière-plan."""
        adj = self._view_adj()
        if adj_identity(adj):
            return qimage_from_bgr(img8)
        key = self._adj_key()
        self.render_pool.submit(self._render_job, name, key, img8, adj)
        proxy, _ = self._proxy(name)
        return qimage_from_bgr(apply_adjust(proxy, adj))

    def _render_job(self, name, key, img8, adj):
        if name != self.cur or key != self._adj_key():
            return                        # obsolète
        try:
            q = qimage_from_bgr(apply_adjust(img8, adj))
        except Exception:
            return
        self.tsig.rendered.emit(name, key, q)

    def _on_rendered(self, name, key, qimg):
        if name == self.cur and key == self._adj_key() and self.cur_size:
            self.view.replace_image(qimg, self.cur_size[0])

    def _render_adjust(self):
        self._save()
        if not self.sess or not self.cur:
            return
        try:
            img8, _ = self._future(self.cur).result()
        except Exception:
            return
        if any(sl.isSliderDown() for sl in self.adj_sliders.values()):
            # pendant le glissement : aperçu réduit seulement
            proxy, _ = self._proxy(self.cur)
            self.view.replace_image(qimage_from_bgr(apply_adjust(proxy, self._view_adj())),
                                    self.cur_size[0])
        else:
            self.view.replace_image(self._display_qimage(self.cur, img8), self.cur_size[0])
        self._update_overlays()

    def reset_adjust(self):
        for sl in self.adj_sliders.values():
            sl.blockSignals(True)
            sl.setValue(0)
            sl.blockSignals(False)
        self._adj_changed()

    # ---- superposition de toutes les photos
    def _stack_wanted(self):
        return self.chk_stack_always.isChecked() or (self._stack_active and self.chk_stack_drag.isChecked())

    def _stack_hold(self, on):
        self._stack_active = on
        self._update_overlays()

    def _stack_jobs(self, lay):
        return [(self.sess.path(x), tuple(self.sess.images[x]["size"]), lay["tf"][x])
                for x in self.sess.names if x in lay["tf"]]

    def _stack_key(self, lay):
        tf = {k: [round(v, 2) for v in t] for k, t in lay["tf"].items()}
        return json.dumps([str(self.sess.folder), tf, self._view_adj(), self._stack_mode()], sort_keys=True)

    def _stack_mode(self):
        return STACK_MODES[self.cmb_stack.currentIndex()][1]

    def _place_stack(self, lay):
        if not self._stack_wanted() or not lay or self.cur not in lay["tf"]:
            self.view.set_stack(None)
            self.lbl_stack.setText("")
            return
        key = self._stack_key(lay)
        if not self._stack or self._stack[0] != key:
            if self._stack_pending != key:
                self._stack_pending = key
                self.stack_pool.submit(self._stack_job, key, self._stack_jobs(lay), self._view_adj(),
                                       self._stack_mode())
            self.lbl_stack.setText(f"Calcul de la superposition ({len(lay['tf'])} photos)…")
            if not self._stack:
                self.view.set_stack(None)
                return                     # l'ancienne reste affichée en attendant, si elle existe
        else:
            self.lbl_stack.setText(f"Superposition de {len(lay['tf'])} photos")
        _, pix, (ux0, uy0, c) = self._stack
        s, ox, oy = lay["tf"][self.cur]
        # bord du pixel 0 du canevas : o = ux0 - 0.5 ; scène = (o - ox) / s + 0.5
        self.view.set_stack(pix, 1 / (c * s), (ux0 - 0.5 - ox) / s + 0.5, (uy0 - 0.5 - oy) / s + 0.5)

    def _stack_job(self, key, jobs, adj, mode):
        try:
            img, geom = compose_stack(jobs, adj, mode=mode)
        except Exception as e:
            print("superposition :", e, file=sys.stderr)
            return
        self.tsig.stack.emit(key, qimage_from_bgr(img), geom)

    def _on_stack(self, key, qimg, geom):
        if self._stack_pending == key:
            self._stack_pending = None
        self._stack = (key, QPixmap.fromImage(qimg), geom)
        self._update_overlays()

    def recenter_frame(self):
        self.frame_center = None
        self._options_changed()

    def _options_changed(self, *_):
        self.lbl_size.setText(f"{self.sl_size.value()} %")
        self._update_enabled()
        if not self.sess:
            return
        self._save()
        self._refresh_all_items()
        self._update_overlays()
        self._update_hint()
        self._update_summary()

    def _update_enabled(self):
        custom = TARGETS[self.cmb_target.currentIndex()][1] == "custom"
        self.sp_tx.setEnabled(custom)
        self.sp_ty.setEnabled(custom)
        self.sl_onion.setEnabled(self.chk_onion.isChecked())
        self.sp_q.setEnabled(self.cmb_fmt.currentData() != "tif")
        has = self.sess is not None
        for b in (self.btn_ref, self.btn_skip, self.btn_clear):
            b.setEnabled(has)

    # ---------------------------------------------------------- dossier
    def _busy(self):
        return bool((self.worker and self.worker.isRunning()) or
                    (getattr(self, "vworker", None) and self.vworker.isRunning()))

    def open_folder(self, folder=None):
        if self._busy():
            return
        if folder is None:
            d = QFileDialog.getExistingDirectory(self, "Dossier des photos")
            if not d:
                return
            folder = d
        folder = Path(folder).expanduser()
        if not folder.is_dir():
            QMessageBox.warning(self, APP, f"Dossier introuvable :\n{folder}")
            return
        self._load_session(Session(folder))

    def open_project(self, path=None):
        if self._busy():
            return
        if path is None:
            start = str(self.sess.folder) if self.sess else str(Path.home())
            path, _ = QFileDialog.getOpenFileName(self, "Ouvrir un projet", start,
                                                  f"Projet {APP} (*.json)")
            if not path:
                return
        try:
            sess = Session.from_project(path)
        except FolderMissing as e:
            QMessageBox.information(self, APP, "Le dossier des photos de ce projet est introuvable "
                                    f"(il a peut-être été déplacé) :\n{e.data.get('folder')}\n\n"
                                    "Indique où il se trouve maintenant.")
            d = QFileDialog.getExistingDirectory(self, "Dossier des photos du projet")
            if not d:
                return
            sess = Session(Path(d), e.project, e.data)
        except ProjectError as e:
            QMessageBox.warning(self, APP, str(e))
            return
        self._load_session(sess)

    def save_project_as(self):
        if not self.sess:
            return
        start = self.sess.project if self.sess.project.parent.exists() else Path.home() / PROJECT_FILE
        path, _ = QFileDialog.getSaveFileName(self, "Enregistrer le projet sous", str(start),
                                              f"Projet {APP} (*.json)")
        if not path:
            return
        if not path.lower().endswith(".json"):
            path += ".json"
        self.sess.project = Path(path).resolve()
        self._save()
        self.statusBar().showMessage(f"Projet enregistré : {path}", 5000)

    def _load_session(self, sess):
        if not sess.names:
            QMessageBox.warning(self, APP, "Aucune image JPEG, PNG ou TIFF dans ce dossier.")
            return
        folder = sess.folder
        self._save()
        self.sess, self.cur = sess, None
        self.cache.clear()
        self.proxies.clear()
        self._stack, self._stack_pending = None, None
        self.ref_pix, self.ref_pix_name = None, None
        self._apply_settings(sess.settings)
        out = sess.settings.get("out")
        self.out_dir = Path(out) if out else folder.parent / (folder.name + "_aligne")
        self.lbl_out.setText(str(self.out_dir))
        self.lbl_folder.setText(f"{folder}\n{len(sess.names)} images")
        self.btn_save_as.setEnabled(True)
        self._update_project_label(True)
        self.setWindowTitle(f"{APP} — {folder.name}")

        self.list.blockSignals(True)
        self.list.clear()
        for name in sess.names:
            it = QListWidgetItem(name)
            self.list.addItem(it)
        self.list.blockSignals(False)
        self._refresh_all_items()
        key = str(folder)
        for name in sess.names:
            self.thumb_pool.submit(self._thumb_job, key, name, sess.path(name))

        n = self._n_pts()
        first = next((i for i, nm in enumerate(sess.names)
                      if not sess.skipped(nm) and not sess.complete(nm, n)), 0)
        if not sess.complete(sess.ref, n):
            first = sess.names.index(sess.ref)
        elif sess.current in sess.names:
            first = sess.names.index(sess.current)
        self.view.fit_mode = True
        self._update_enabled()
        self.list.setCurrentRow(first)
        self._update_summary()
        self._resume_msg = ""
        if sess.resumed:
            done = sum(sess.complete(x, n) for x in sess.names)
            self._resume_msg = f"travail repris ({done}/{len(sess.names)} photos pointées"
            if sess.extra:
                self._resume_msg += f", {len(sess.extra)} absente(s) du dossier conservée(s)"
            self._resume_msg += ")"
        self._save()                      # crée / met à jour le fichier projet tout de suite

    def _thumb_job(self, key, name, path):
        try:
            q = load_thumb(path)
        except Exception:
            return
        self.tsig.ready.emit(key, name, q)

    def _on_thumb(self, key, name, qimg):
        if self.sess and str(self.sess.folder) == key and name in self.sess.names:
            it = self.list.item(self.sess.names.index(name))
            if it:
                it.setIcon(QIcon(QPixmap.fromImage(qimg)))

    def _refresh_item(self, name):
        s, n = self.sess, self._n_pts()
        it = self.list.item(s.names.index(name))
        if s.skipped(name):
            mark = "⊘"
        elif s.complete(name, n):
            mark = "✓"
        elif s.pts(name):
            mark = "◐"
        else:
            mark = "·"
        star = "  ★" if name == s.ref else ""
        it.setText(f"{mark}  {name}{star}")
        it.setForeground(QColor(130, 130, 130) if s.skipped(name) else self.list.palette().text().color())

    def _refresh_all_items(self):
        if self.sess:
            for name in self.sess.names:
                self._refresh_item(name)

    def choose_output(self):
        start = str(self.out_dir.parent if self.out_dir else Path.home())
        d = QFileDialog.getExistingDirectory(self, "Dossier de sortie", start)
        if d:
            self.out_dir = Path(d)
            self.lbl_out.setText(d)
            self._save()

    # ---------------------------------------------------------- navigation
    def _future(self, name):
        f = self.cache.get(name)
        if f is None:
            f = self.pool.submit(load_for_display, self.sess.path(name))
            self.cache[name] = f
        return f

    def _on_row(self, row):
        if not self.sess or row < 0:
            return
        name = self.sess.names[row]
        prev = self.sess.pts(self.cur) if self.cur else []
        QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
        try:
            img8, size = self._future(name).result()
        except Exception as e:
            self.cache.pop(name, None)
            self.statusBar().showMessage(str(e))
            return
        finally:
            QApplication.restoreOverrideCursor()
        self.cur, self.cur_size = name, size
        pts = self.sess.pts(name)
        c = pts[0] if pts else (prev[0] if prev else None)
        self.view.set_image(self._display_qimage(name, img8), QPointF(c[0] + .5, c[1] + .5) if c else None,
                            size)
        self._update_overlays()
        self._update_hint()
        self._update_status()

        names, keep = self.sess.names, {name, self.sess.ref}
        for d in (1, -1, 2):
            j = row + d
            if 0 <= j < len(names):
                keep.add(names[j])
                self._future(names[j])
        for k in list(self.cache):
            if k not in keep:
                self.cache.pop(k)

    def _step(self, d):
        if not self.sess:
            return
        row = self.list.currentRow() + d
        if 0 <= row < len(self.sess.names):
            self.list.setCurrentRow(row)

    # ---------------------------------------------------------- édition
    def _on_click(self, x, y, alt):
        if not self.sess or not self.cur:
            return
        n = self._n_pts()
        e = self.sess.entry(self.cur)
        was_complete = self.sess.complete(self.cur, n)
        e.pop("skip", None)
        pts = [list(p) for p in e.get("pts", [])]
        if alt and n == 2 and pts:
            idx = 1
        elif len(pts) < n:
            idx = len(pts)
        else:
            idx = min(range(n), key=lambda i: math.dist(pts[i], (x, y)))
        if idx < len(pts):
            pts[idx] = [x, y]
        else:
            pts.append([x, y])
        e["pts"], e["size"] = pts, list(self.cur_size)
        self._save()
        self._refresh_item(self.cur)
        self._update_overlays()
        self._update_hint()
        self._update_summary()
        if self.chk_auto.isChecked() and not was_complete and self.sess.complete(self.cur, n):
            QTimer.singleShot(120, lambda: self._step(1))

    def set_reference(self):
        if not self.sess or not self.cur or self.cur == self.sess.ref:
            return
        old = self.sess.ref
        self.sess.ref = self.cur
        self.sess.entry(self.cur).pop("skip", None)
        self._save()
        self._refresh_item(old)
        self._refresh_item(self.cur)
        self._update_overlays()
        self._update_hint()
        self._update_summary()

    def toggle_skip(self):
        if not self.sess or not self.cur:
            return
        if self.cur == self.sess.ref:
            self.statusBar().showMessage("La référence ne peut pas être ignorée.", 3000)
            return
        e = self.sess.entry(self.cur)
        e["skip"] = not e.get("skip", False)
        self._save()
        self._refresh_item(self.cur)
        self._update_overlays()
        self._update_hint()
        self._update_summary()
        if e["skip"] and self.chk_auto.isChecked():
            QTimer.singleShot(120, lambda: self._step(1))

    def clear_points(self):
        if not self.sess or not self.cur:
            return
        self.sess.entry(self.cur)["pts"] = []
        self._save()
        self._refresh_item(self.cur)
        self._update_overlays()
        self._update_hint()
        self._update_summary()

    # ---------------------------------------------------------- affichage
    def _ref_pixmap(self):
        ref = self.sess.ref
        key = (ref, self._adj_key())
        if self.ref_pix_name != key:
            proxy, f = self._proxy(ref)
            qimg = qimage_from_bgr(apply_adjust(proxy, self._view_adj()))
            self.ref_pix, self.ref_pix_name = (QPixmap.fromImage(qimg), f), key
        return self.ref_pix

    def _update_overlays(self):
        if not self.sess or not self.cur:
            return
        n = self._n_pts()
        self.view.set_points([] if self.sess.skipped(self.cur) else self.sess.pts(self.cur)[:n])
        lay, _ = self._layout()
        tf = lay["tf"] if lay else {}

        if self.chk_frame.isChecked() and self.cur in tf:
            s, ox, oy = tf[self.cur]
            cx0, cy0, cx1, cy1 = lay["rect"]
            # o = s*p + off  ->  p = (o - off)/s ; scène = p + 0.5 ; bord pixel = o ± 0.5
            x0 = (cx0 - 0.5 - ox) / s + 0.5
            y0 = (cy0 - 0.5 - oy) / s + 0.5
            x1 = (cx1 - 0.5 - ox) / s + 0.5
            y1 = (cy1 - 0.5 - oy) / s + 0.5
            self.view.set_frame(QRectF(QPointF(x0, y0), QPointF(x1, y1)))
        else:
            self.view.set_frame(None)

        self._place_stack(lay)

        ref = self.sess.ref
        if self.chk_onion.isChecked() and self.cur != ref and self.cur in tf and ref in tf:
            sr, oxr, oyr = tf[ref]
            sc, oxc, oyc = tf[self.cur]
            k = sr / sc
            dx, dy = (oxr - oxc) / sc, (oyr - oyc) / sc
            try:
                pix, f = self._ref_pixmap()
                self.view.set_onion(pix, k, dx + 0.5 - 0.5 * k, dy + 0.5 - 0.5 * k,
                                    self.sl_onion.value() / 100, f)
            except Exception:
                self.view.set_onion(None)
        else:
            self.view.set_onion(None)

    def _update_hint(self):
        if not self.sess or not self.cur:
            return
        s, n = self.sess, self._n_pts()
        pts = s.pts(self.cur)
        if s.skipped(self.cur):
            msg = "Image ignorée. Un clic sur l'œil la réintègre."
        elif not pts:
            msg = "Clique sur l'<b style='color:#ffd200'>ŒIL</b>. Zoome (molette / pincement) pour viser au pixel près."
        elif n == 2 and len(pts) < 2:
            msg = "Clique sur le <b style='color:#ff3cc8'>2e REPÈRE</b> (ex. pointe du bec)."
        else:
            msg = "Points placés. Un clic déplace le point le plus proche."
        if self.cur == s.ref:
            msg = "<b>★ Image de référence</b><br>" + msg
        self.lbl_hint.setText(msg)
        self.btn_skip.setText("Réintégrer cette image" if s.skipped(self.cur) else "Ignorer cette image")
        self.btn_ref.setEnabled(self.cur != s.ref)

    def _update_summary(self):
        if not self.sess:
            return
        s, n = self.sess, self._n_pts()
        done = sum(s.complete(x, n) for x in s.names)
        skip = sum(s.skipped(x) for x in s.names)
        todo = len(s.names) - done - skip
        lay, err = self._layout()
        txt = f"<b>{done}</b> pointées · {skip} ignorées · {todo} à faire"
        if lay:
            cx0, cy0, cx1, cy1 = lay["rect"]
            w, h = cx1 - cx0, cy1 - cy0
            ex, ey = lay["T"][0] - cx0, lay["T"][1] - cy0
            txt += f"<br>Sortie : {w} × {h} px — œil à {100 * ex / w:.0f} % / {100 * ey / h:.0f} %"
            if not (0 <= ex < w and 0 <= ey < h):
                txt += "<br><span style='color:#e0a000'>⚠ L'œil est hors du cadre.</span>"
        elif err:
            txt += f"<br><span style='color:#e0a000'>{err}</span>"
        self.lbl_summary.setText(txt)
        self.btn_export.setEnabled(bool(lay) or bool(self.worker and self.worker.isRunning()))
        vrun = bool(getattr(self, "vworker", None) and self.vworker.isRunning())
        self.btn_video.setEnabled(vrun or (bool(lay) and bool(self.ffmpeg)))
        if lay:
            cx0, cy0, cx1, cy1 = lay["rect"]
            vw, vh = video_size(cx1 - cx0, cy1 - cy0, self.cmb_res.currentData())
            n_ph = len(lay["tf"])
            fps = self._fps()
            if fps is None:
                self.lbl_video.setText("<span style='color:#e0a000'>Cadence invalide "
                                       "(nombre entre 1 et 120, ex. 18 ou 23.976)</span>")
                self.btn_video.setEnabled(vrun)
            else:
                fps = float(fps)
                dur = n_ph / fps + self.sp_hold.value()
                self.lbl_video.setText(f"{n_ph} photos · {fps:.3g} photos/s · {dur:.1f} s"
                                       f" · {vw} × {vh}")
        else:
            self.lbl_video.setText("")
        if not self.ffmpeg:
            self.lbl_video.setText("<span style='color:#e0a000'>ffmpeg introuvable : "
                                   "python3 -m pip install imageio-ffmpeg</span>")

    def _update_status(self, *_):
        if not self.sess or not self.cur or not self.cur_size:
            return
        i = self.sess.names.index(self.cur) + 1
        W, H = self.cur_size
        self.statusBar().showMessage(f"{i}/{len(self.sess.names)}  ·  {self.cur}  ·  {W} × {H} px"
                                     f"  ·  zoom {100 * self.view.zoom_level():.0f} %")

    # ---------------------------------------------------------- export
    def export(self):
        if self.worker and self.worker.isRunning():
            self.worker.cancel = True
            return
        lay, err = self._layout()
        if not lay:
            QMessageBox.warning(self, APP, err or "Rien à exporter.")
            return
        out = self.out_dir
        if out.resolve() == self.sess.folder.resolve():
            QMessageBox.warning(self, APP, "Choisis un dossier de sortie différent du dossier source.")
            return
        n = self._n_pts()
        todo = [x for x in self.sess.names if not self.sess.skipped(x) and not self.sess.complete(x, n)]
        if todo:
            r = QMessageBox.question(self, APP, f"{len(todo)} image(s) sans point seront ignorées. Continuer ?")
            if r != QMessageBox.StandardButton.Yes:
                return
        try:
            out.mkdir(parents=True, exist_ok=True)
        except OSError as e:
            QMessageBox.warning(self, APP, str(e))
            return
        jobs = [(self.sess.path(x), tuple(self.sess.images[x]["size"]), lay["tf"][x])
                for x in self.sess.names if x in lay["tf"]]
        exif = self.exiftool if self.chk_exif.isChecked() else None
        self.worker = ExportWorker(jobs, lay["rect"], out, self.cmb_fmt.currentData(),
                                   self.sp_q.value(), exif, self._adj())
        self.worker.progress.connect(lambda i, name: self.progress.setValue(i))
        self.worker.done.connect(self._export_done)
        self.worker.failed.connect(self._export_failed)
        self.progress.setRange(0, len(jobs))
        self.progress.setValue(0)
        self.progress.show()
        self.btn_export.setText("Annuler")
        self.worker.start()

    def _export_finished(self):
        self.progress.hide()
        self.btn_export.setText("Exporter la série alignée")
        self._update_summary()

    def _export_done(self, n):
        self._export_finished()
        box = QMessageBox(self)
        box.setWindowTitle(APP)
        box.setText(f"{n} image(s) exportée(s) dans :\n{self.out_dir}")
        open_btn = box.addButton("Ouvrir le dossier", QMessageBox.ButtonRole.AcceptRole)
        box.addButton(QMessageBox.StandardButton.Ok)
        box.exec()
        if box.clickedButton() == open_btn:
            QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.out_dir)))

    def _export_failed(self, msg):
        self._export_finished()
        QMessageBox.critical(self, APP, f"Erreur pendant l'export :\n{msg}")

    # ---------------------------------------------------------- vidéo
    def _fps(self):
        return parse_fps(self.cmb_fps.currentText())

    def _sound_source(self):
        return SOUND_SOURCES[self.cmb_sound.currentIndex()][1]

    def _set_sound_file(self, path, save=True, source=None):
        """path : fichier perso (ou None) ; source : 'sony' / 'file'."""
        self.sound_file = path
        if source is None:
            source = "file" if path else self._sound_source()
        # Fichier absent, ou ancien projet réglé sur le son synthétique (retiré) : Sony.
        if (source == "file" and not path) or source not in [k for _, k in SOUND_SOURCES]:
            source = "sony"
        self.cmb_sound.blockSignals(True)
        self.cmb_sound.setCurrentIndex([k for _, k in SOUND_SOURCES].index(source))
        self.cmb_sound.blockSignals(False)
        self._sound_cache = None
        self.lbl_sound.setText({"sony": "Fin d'un déclenchement Sony A7 II (127 ms)",
                                "file": f"Fichier : {path.name}" if path else ""}[source])
        if save:
            self._save()

    def _on_sound_source(self, idx):
        src = SOUND_SOURCES[idx][1]
        if src == "file":
            if not self.choose_sound():
                self._set_sound_file(self.sound_file, source="file" if self.sound_file else "sony")
        else:
            self._set_sound_file(self.sound_file, source=src)

    def choose_sound(self):
        f, _ = QFileDialog.getOpenFileName(self, "Son de déclencheur", str(Path.home()),
                                           "Audio (*.wav *.aif *.aiff *.mp3 *.m4a *.flac *.caf)")
        if not f:
            return False
        old = self.sound_file
        self._set_sound_file(Path(f), source="file")
        try:
            self._sound()
            return True
        except Exception as e:
            QMessageBox.warning(self, APP, str(e))
            self._set_sound_file(old, source="file" if old else "sony")
            return False

    def _sound(self):
        if self._sound_cache is None:
            if self._sound_source() == "file" and self.sound_file:
                if not self.ffmpeg:
                    raise RuntimeError("ffmpeg est nécessaire pour lire un son personnalisé.")
                self._sound_cache = load_sound(self.sound_file, self.ffmpeg)
            else:
                self._sound_cache = sony_sound()
        return self._sound_cache

    def play_sound(self):
        """Écoute une courte rafale au rythme de la vidéo."""
        try:
            from PySide6.QtMultimedia import QSoundEffect
        except Exception:
            QMessageBox.information(self, APP, "Lecture audio indisponible (QtMultimedia).")
            return
        try:
            fps = float(self._fps() or 30)
            n = max(1, min(8, int(round(fps))))              # ~1 s de rafale
            x = build_audio(n, n + int(fps // 3), fps, self._sound(), self.sl_vol.value() / 100)
            path = Path(tempfile.gettempdir()) / "pinpoint_preview.wav"
            write_wav(path, x)
            self._player = QSoundEffect(self)
            self._player.setSource(QUrl.fromLocalFile(str(path)))
            self._player.setVolume(1.0)
            self._player.play()
        except Exception as e:
            QMessageBox.warning(self, APP, str(e))

    def export_video(self):
        if getattr(self, "vworker", None) and self.vworker.isRunning():
            self.vworker.cancel = True
            return
        if not self.ffmpeg:
            QMessageBox.warning(self, APP, "ffmpeg introuvable.\nInstalle-le avec :\n"
                                "python3 -m pip install imageio-ffmpeg")
            return
        lay, err = self._layout()
        if not lay:
            QMessageBox.warning(self, APP, err or "Rien à exporter.")
            return
        if self._fps() is None:
            QMessageBox.warning(self, APP, "Cadence invalide : indique un nombre entre 1 et 120.")
            return
        start = (self.out_dir.parent if self.out_dir else self.sess.folder.parent) / \
            f"{self.sess.folder.name}.mp4"
        dst, _ = QFileDialog.getSaveFileName(self, "Enregistrer la vidéo", str(start), "Vidéo MP4 (*.mp4)")
        if not dst:
            return
        if not dst.lower().endswith(".mp4"):
            dst += ".mp4"
        sound = None
        if self.chk_sound.isChecked():
            try:
                sound = self._sound()
            except Exception as e:
                QMessageBox.warning(self, APP, str(e))
                return
        jobs = [(self.sess.path(x), tuple(self.sess.images[x]["size"]), lay["tf"][x])
                for x in self.sess.names if x in lay["tf"]]
        self.vworker = VideoWorker(jobs, lay["rect"], dst, self.ffmpeg, self._fps(),
                                   self.sp_hold.value(), self.cmb_res.currentData(),
                                   self._adj(), sound, self.sl_vol.value() / 100)
        self.vworker.progress.connect(lambda i, name: self.vprogress.setValue(i))
        self.vworker.done.connect(self._video_done)
        self.vworker.failed.connect(self._video_failed)
        self.vprogress.setRange(0, len(jobs))
        self.vprogress.setValue(0)
        self.vprogress.show()
        self.btn_video.setText("Annuler")
        self.vworker.start()

    def _video_finished(self):
        self.vprogress.hide()
        self.btn_video.setText("Exporter la vidéo…")
        self._update_summary()

    def _video_done(self, path):
        self._video_finished()
        box = QMessageBox(self)
        box.setWindowTitle(APP)
        box.setText(f"Vidéo enregistrée :\n{path}")
        play = box.addButton("Lire", QMessageBox.ButtonRole.AcceptRole)
        box.addButton(QMessageBox.StandardButton.Ok)
        box.exec()
        if box.clickedButton() == play:
            QDesktopServices.openUrl(QUrl.fromLocalFile(path))

    def _video_failed(self, msg):
        self._video_finished()
        if msg:
            QMessageBox.critical(self, APP, f"Erreur pendant l'export vidéo :\n{msg}")

    # ---------------------------------------------------------- divers
    def dragEnterEvent(self, ev):
        if ev.mimeData().hasUrls():
            ev.acceptProposedAction()

    def dropEvent(self, ev):
        for url in ev.mimeData().urls():
            p = Path(url.toLocalFile())
            if p.is_file() and p.suffix.lower() == ".json":
                self.open_project(p)
                break
            if p.is_file():
                p = p.parent
            if p.is_dir():
                self.open_folder(p)
                break

    def closeEvent(self, ev):
        self._save()
        for wk in (self.worker, getattr(self, "vworker", None)):
            if wk and wk.isRunning():
                wk.cancel = True
                wk.wait(5000)
        self.pool.shutdown(wait=False, cancel_futures=True)
        self.thumb_pool.shutdown(wait=False, cancel_futures=True)
        self.render_pool.shutdown(wait=False, cancel_futures=True)
        self.stack_pool.shutdown(wait=False, cancel_futures=True)
        super().closeEvent(ev)


def main():
    app = QApplication(sys.argv)
    app.setApplicationName(APP)
    arg = sys.argv[1] if len(sys.argv) > 1 else None
    win = MainWindow(arg)
    win.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
