Import USB + éditeur complet + widgets

- Service USB watcher (polling montages, scan photos récursif)
- Écran accueil avec détection USB live (bouton vert + compteur)
- Éditeur : crop interactif avec overlay/grille des tiers
- Éditeur : filtres Pillow (N&B, sépia, vintage, contraste)
- Éditeur : stickers/goodies draggable + cadres overlay
- Éditeur : multi-pose (5 templates, remplissage auto)
- Éditeur : texte éditable draggable
- Éditeur : rotation, annulation, export scène PNG
- Widget CropOverlay avec poignées tactiles
- Widget GoodiesPanel (stickers + cadres depuis assets/)
- Widget MultiposeDialog (prévisualisation templates)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 19:28:20 +01:00
parent 6598e4073f
commit d6d431689c
11 changed files with 811 additions and 54 deletions

View File

View File

@@ -0,0 +1,123 @@
"""Overlay de recadrage interactif sur QGraphicsScene."""
from PyQt6.QtWidgets import QGraphicsRectItem, QGraphicsItem
from PyQt6.QtCore import Qt, QRectF, QPointF
from PyQt6.QtGui import QPen, QBrush, QColor, QPainter
HANDLE_SIZE = 20
class CropOverlay(QGraphicsRectItem):
"""Rectangle de recadrage avec poignées tactiles redimensionnables."""
def __init__(self, rect, parent=None):
super().__init__(rect, parent)
self.setPen(QPen(QColor(255, 255, 255, 200), 2, Qt.PenStyle.DashLine))
self.setBrush(QBrush(Qt.BrushStyle.NoBrush))
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True)
self.setAcceptHoverEvents(True)
self._resizing = False
self._resize_edge = None
self._start_rect = None
self._start_pos = None
self._bounds = None
def set_bounds(self, rect):
"""Limites dans lesquelles le crop peut bouger (la photo)."""
self._bounds = rect
def paint(self, painter, option, widget=None):
super().paint(painter, option, widget)
r = self.rect()
# Assombrir l'extérieur
if self._bounds:
painter.setBrush(QBrush(QColor(0, 0, 0, 120)))
painter.setPen(Qt.PenStyle.NoPen)
# Les 4 zones sombres autour du crop
b = self.mapRectFromScene(self._bounds)
# Haut
painter.drawRect(QRectF(b.left(), b.top(), b.width(), r.top() - b.top()))
# Bas
painter.drawRect(QRectF(b.left(), r.bottom(), b.width(), b.bottom() - r.bottom()))
# Gauche
painter.drawRect(QRectF(b.left(), r.top(), r.left() - b.left(), r.height()))
# Droite
painter.drawRect(QRectF(r.right(), r.top(), b.right() - r.right(), r.height()))
# Grille des tiers
painter.setPen(QPen(QColor(255, 255, 255, 80), 1))
for i in range(1, 3):
x = r.left() + r.width() * i / 3
y = r.top() + r.height() * i / 3
painter.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom()))
painter.drawLine(QPointF(r.left(), y), QPointF(r.right(), y))
# Poignées aux coins
painter.setBrush(QBrush(QColor(255, 255, 255)))
painter.setPen(Qt.PenStyle.NoPen)
hs = HANDLE_SIZE / 2
for corner in [r.topLeft(), r.topRight(), r.bottomLeft(), r.bottomRight()]:
painter.drawEllipse(corner, hs, hs)
def mousePressEvent(self, event):
pos = event.pos()
edge = self._detect_edge(pos)
if edge:
self._resizing = True
self._resize_edge = edge
self._start_rect = QRectF(self.rect())
self._start_pos = pos
event.accept()
else:
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self._resizing:
delta = event.pos() - self._start_pos
r = QRectF(self._start_rect)
e = self._resize_edge
if "left" in e:
r.setLeft(r.left() + delta.x())
if "right" in e:
r.setRight(r.right() + delta.x())
if "top" in e:
r.setTop(r.top() + delta.y())
if "bottom" in e:
r.setBottom(r.bottom() + delta.y())
# Taille minimum
if r.width() >= 50 and r.height() >= 50:
self.setRect(r)
event.accept()
else:
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self._resizing:
self._resizing = False
self._resize_edge = None
event.accept()
else:
super().mouseReleaseEvent(event)
def _detect_edge(self, pos):
r = self.rect()
margin = HANDLE_SIZE
edges = ""
if abs(pos.y() - r.top()) < margin:
edges += "top"
elif abs(pos.y() - r.bottom()) < margin:
edges += "bottom"
if abs(pos.x() - r.left()) < margin:
edges += "left"
elif abs(pos.x() - r.right()) < margin:
edges += "right"
return edges or None
def get_crop_rect(self):
"""Retourne le rectangle de crop en coordonnées scène."""
return self.mapRectToScene(self.rect())

View File

@@ -0,0 +1,104 @@
"""Panneau de sélection de goodies (stickers, cadres)."""
import os
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QScrollArea, QGridLayout,
QPushButton, QLabel
)
from PyQt6.QtCore import Qt, pyqtSignal, QSize
from PyQt6.QtGui import QFont, QPixmap, QIcon
GOODIES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "assets", "goodies")
FRAMES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "assets", "frames")
class GoodiesPanel(QWidget):
"""Panneau latéral de sélection de stickers et cadres."""
sticker_selected = pyqtSignal(str) # chemin du sticker
frame_selected = pyqtSignal(str) # chemin du cadre
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedWidth(200)
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
# Titre stickers
sticker_label = QLabel("Stickers")
sticker_label.setFont(QFont("", 14, QFont.Weight.Bold))
sticker_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(sticker_label)
# Grille stickers
sticker_scroll = QScrollArea()
sticker_scroll.setWidgetResizable(True)
sticker_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
sticker_widget = QWidget()
self.sticker_grid = QGridLayout(sticker_widget)
self.sticker_grid.setSpacing(5)
sticker_scroll.setWidget(sticker_widget)
layout.addWidget(sticker_scroll, 1)
# Titre cadres
frame_label = QLabel("Cadres")
frame_label.setFont(QFont("", 14, QFont.Weight.Bold))
frame_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(frame_label)
# Grille cadres
frame_scroll = QScrollArea()
frame_scroll.setWidgetResizable(True)
frame_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
frame_widget = QWidget()
self.frame_grid = QGridLayout(frame_widget)
self.frame_grid.setSpacing(5)
frame_scroll.setWidget(frame_widget)
layout.addWidget(frame_scroll, 1)
self._load_assets()
def _load_assets(self):
self._load_items(GOODIES_DIR, self.sticker_grid, self.sticker_selected)
self._load_items(FRAMES_DIR, self.frame_grid, self.frame_selected)
def _load_items(self, directory, grid, signal):
directory = os.path.normpath(directory)
if not os.path.isdir(directory):
return
items = sorted([
f for f in os.listdir(directory)
if f.lower().endswith((".png", ".svg", ".webp"))
])
cols = 2
for i, filename in enumerate(items):
path = os.path.join(directory, filename)
btn = QPushButton()
btn.setFixedSize(85, 85)
pixmap = QPixmap(path)
if not pixmap.isNull():
btn.setIcon(QIcon(pixmap))
btn.setIconSize(QSize(75, 75))
btn.setStyleSheet("""
QPushButton {
border: 2px solid transparent;
border-radius: 8px;
background: #444;
}
QPushButton:pressed { border-color: #2196F3; }
""")
btn.clicked.connect(lambda checked, p=path: signal.emit(p))
grid.addWidget(btn, i // cols, i % cols)
def refresh(self):
"""Recharge les assets depuis le disque."""
# Clear grids
for grid in (self.sticker_grid, self.frame_grid):
while grid.count():
item = grid.takeAt(0)
if item.widget():
item.widget().deleteLater()
self._load_assets()

View File

@@ -0,0 +1,122 @@
"""Dialogue de sélection de template multi-pose."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QGridLayout
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QPainter, QColor, QPen
# Templates : liste de rectangles normalisés (x, y, w, h) en pourcentage [0-1]
TEMPLATES = {
"2 horizontal": [
(0.0, 0.0, 0.5, 1.0),
(0.5, 0.0, 0.5, 1.0),
],
"2 vertical": [
(0.0, 0.0, 1.0, 0.5),
(0.0, 0.5, 1.0, 0.5),
],
"3 gauche": [
(0.0, 0.0, 0.5, 1.0),
(0.5, 0.0, 0.5, 0.5),
(0.5, 0.5, 0.5, 0.5),
],
"4 grille": [
(0.0, 0.0, 0.5, 0.5),
(0.5, 0.0, 0.5, 0.5),
(0.0, 0.5, 0.5, 0.5),
(0.5, 0.5, 0.5, 0.5),
],
"3 bande": [
(0.0, 0.0, 0.333, 1.0),
(0.333, 0.0, 0.334, 1.0),
(0.667, 0.0, 0.333, 1.0),
],
}
class TemplatePreview(QPushButton):
"""Bouton avec prévisualisation d'un template."""
def __init__(self, name, zones, parent=None):
super().__init__(parent)
self.name = name
self.zones = zones
self.setFixedSize(150, 120)
self.setStyleSheet("""
QPushButton { border: 2px solid #555; border-radius: 8px; background: #333; }
QPushButton:pressed { border-color: #2196F3; }
""")
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width() - 20, self.height() - 35
ox, oy = 10, 5
painter.setPen(QPen(QColor(255, 255, 255, 150), 2))
for (zx, zy, zw, zh) in self.zones:
rect_x = ox + zx * w + 2
rect_y = oy + zy * h + 2
rect_w = zw * w - 4
rect_h = zh * h - 4
painter.setBrush(QColor(33, 150, 243, 60))
painter.drawRoundedRect(int(rect_x), int(rect_y), int(rect_w), int(rect_h), 4, 4)
# Nom en bas
painter.setPen(QColor(255, 255, 255))
painter.setFont(QFont("", 10))
painter.drawText(
0, self.height() - 25, self.width(), 20,
Qt.AlignmentFlag.AlignCenter, self.name
)
painter.end()
class MultiposeDialog(QDialog):
"""Dialogue pour choisir un template multi-pose."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Multi-pose")
self.selected_template = None
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
title = QLabel("Choisir un template")
title.setFont(QFont("", 18, QFont.Weight.Bold))
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)
grid = QGridLayout()
grid.setSpacing(15)
for i, (name, zones) in enumerate(TEMPLATES.items()):
preview = TemplatePreview(name, zones)
preview.clicked.connect(lambda checked, n=name: self._select(n))
grid.addWidget(preview, i // 3, i % 3)
layout.addLayout(grid)
# Annuler
cancel = QPushButton("Annuler")
cancel.setFont(QFont("", 14))
cancel.clicked.connect(self.reject)
layout.addWidget(cancel)
def _select(self, name):
self.selected_template = name
self.accept()
@staticmethod
def get_template(parent=None):
dialog = MultiposeDialog(parent)
if dialog.exec() == QDialog.DialogCode.Accepted and dialog.selected_template:
return dialog.selected_template, TEMPLATES[dialog.selected_template]
return None, None