feat: auto-preview, timestamped/grouped output, folder memory; fix batch format
- Auto-preview the first image after a folder loads (both panels), so the canvas isn't empty (§17.8). - Group exports as <output_dir>/<timestamp>/<print_stem>/<garment_stem>.<ext>; one timestamp per export run, shared across the batch (§17.9). - Remember the last garment/print folders and reopen the picker there; persisted via config, wired through main_window (§17.10). - Shrink the 3 box templates further and lower them (chest placement). - Fix: batch export ignored the chosen format/quality/output dir because the queue never received ExportOptions. Export panel now emits export_options_changed → queue.set_export_options (+ initial sync) (§17.11). Docs: PRD 6.1/6.8/9, architecture 4.12, UI design 5.1, tasks.md 17.8–17.11. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -260,6 +260,10 @@ class MainWindow(QMainWindow):
|
||||
self.template_panel.template_applied.connect(self.queue_panel.set_transform)
|
||||
self.template_panel.template_applied.connect(self.export_panel.set_transform)
|
||||
|
||||
# Output settings (dir/format/quality) flow to the queue for batch export
|
||||
self.export_panel.export_options_changed.connect(self.queue_panel.set_export_options)
|
||||
self.queue_panel.set_export_options(self.export_panel.current_options()) # initial sync
|
||||
|
||||
def _load_print_preview(self, path):
|
||||
"""Load a print into the canvas and sync its native size to the template panel."""
|
||||
self.image_canvas.load_print(path)
|
||||
@@ -414,9 +418,15 @@ class MainWindow(QMainWindow):
|
||||
except ValueError:
|
||||
logger.warning("Unknown last_batch_mode %r, keeping default", raw_mode)
|
||||
|
||||
# Restore where the folder pickers open (last-used folders)
|
||||
self.image_list_panel.set_garment_start_dir(self._config.get("last_garment_dir", ""))
|
||||
self.image_list_panel.set_print_start_dir(self._config.get("last_print_dir", ""))
|
||||
|
||||
# Persist future changes (connected after restore to avoid echo writes)
|
||||
self.template_panel.template_changed.connect(self._on_template_persist)
|
||||
self.queue_panel.batch_mode_changed.connect(self._on_batch_mode_persist)
|
||||
self.image_list_panel.garment_folder_opened.connect(self._on_garment_dir_persist)
|
||||
self.image_list_panel.print_folder_opened.connect(self._on_print_dir_persist)
|
||||
|
||||
def _on_template_persist(self, name):
|
||||
self._config["last_template"] = name
|
||||
@@ -426,6 +436,14 @@ class MainWindow(QMainWindow):
|
||||
self._config["last_batch_mode"] = getattr(mode, "value", str(mode))
|
||||
save_config(self._config)
|
||||
|
||||
def _on_garment_dir_persist(self, path):
|
||||
self._config["last_garment_dir"] = path
|
||||
save_config(self._config)
|
||||
|
||||
def _on_print_dir_persist(self, path):
|
||||
self._config["last_print_dir"] = path
|
||||
save_config(self._config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Style (ref: docs/07-ui-design.md §10)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
@@ -17,7 +17,11 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from core.composer import compose
|
||||
from core.models import ExportOptions, TransformState
|
||||
from services.file_service import get_output_dir, make_safe_output_path
|
||||
from services.file_service import (
|
||||
get_output_dir,
|
||||
make_safe_output_path,
|
||||
timestamped_run_dir,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +29,8 @@ logger = logging.getLogger(__name__)
|
||||
class ExportPanel(QWidget):
|
||||
"""Right-panel section: output settings and single-image export."""
|
||||
|
||||
export_options_changed = Signal(object) # ExportOptions when dir/format/quality changes
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._garment_path: Optional[str] = None
|
||||
@@ -66,6 +72,7 @@ class ExportPanel(QWidget):
|
||||
self._dir_input.setReadOnly(True)
|
||||
self._dir_input.setPlaceholderText("输出目录")
|
||||
self._dir_input.setText(str(get_output_dir()))
|
||||
self._dir_input.textChanged.connect(self._emit_options)
|
||||
|
||||
browse_btn = QPushButton("浏览…")
|
||||
browse_btn.setObjectName("exportBrowseBtn")
|
||||
@@ -92,6 +99,7 @@ class ExportPanel(QWidget):
|
||||
self._quality_combo.addItem(label, val)
|
||||
self._quality_combo.setCurrentIndex(1) # default: 高 (90)
|
||||
self._quality_combo.setEnabled(False) # enabled only for JPG
|
||||
self._quality_combo.currentIndexChanged.connect(self._emit_options)
|
||||
|
||||
# 格式 and 质量 share one row (quality lights up only for JPG)
|
||||
row.addWidget(QLabel("格式"))
|
||||
@@ -133,11 +141,24 @@ class ExportPanel(QWidget):
|
||||
"""Update the operation-scope hint label."""
|
||||
self._hint_lbl.setText(text)
|
||||
|
||||
def current_options(self) -> ExportOptions:
|
||||
"""Build ExportOptions from the current output settings."""
|
||||
fmt = self._format_combo.currentData()
|
||||
return ExportOptions(
|
||||
output_dir=self._dir_input.text().strip(),
|
||||
output_format=fmt,
|
||||
quality=self._quality_combo.currentData() if fmt == "JPG" else 95,
|
||||
)
|
||||
|
||||
# ── slot handlers ────────────────────────────────────────────────────────
|
||||
|
||||
def _emit_options(self, *_):
|
||||
self.export_options_changed.emit(self.current_options())
|
||||
|
||||
def _on_format_changed(self, _index: int):
|
||||
fmt = self._format_combo.currentData()
|
||||
self._quality_combo.setEnabled(fmt == "JPG")
|
||||
self._emit_options()
|
||||
|
||||
def _browse_output_dir(self):
|
||||
folder = QFileDialog.getExistingDirectory(
|
||||
@@ -158,9 +179,8 @@ class ExportPanel(QWidget):
|
||||
QMessageBox.information(self, "提示", "当前没有可导出的印花参数。")
|
||||
return
|
||||
|
||||
out_dir = self._dir_input.text().strip() or str(get_output_dir())
|
||||
fmt = self._format_combo.currentData()
|
||||
quality = self._quality_combo.currentData() if fmt == "JPG" else 95
|
||||
options = self.current_options()
|
||||
out_dir = options.output_dir or str(get_output_dir())
|
||||
|
||||
out_dir_path = Path(out_dir)
|
||||
try:
|
||||
@@ -173,13 +193,10 @@ class ExportPanel(QWidget):
|
||||
)
|
||||
return
|
||||
|
||||
options = ExportOptions(
|
||||
output_dir=out_dir,
|
||||
output_format=fmt,
|
||||
quality=quality,
|
||||
)
|
||||
# Group this export under a timestamped run folder (compose creates it)
|
||||
run_dir = timestamped_run_dir(out_dir)
|
||||
output_path = make_safe_output_path(
|
||||
out_dir, self._garment_path, self._print_path, fmt
|
||||
run_dir, self._garment_path, self._print_path, options.output_format
|
||||
)
|
||||
|
||||
result = compose(
|
||||
|
||||
@@ -155,12 +155,14 @@ class _AssetPanel(QWidget):
|
||||
|
||||
preview_changed = Signal(object) # ImageAsset | None
|
||||
assets_changed = Signal(list) # List[ImageAsset]
|
||||
folder_opened = Signal(str) # chosen folder path (for persistence)
|
||||
|
||||
def __init__(self, title: str, open_text: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._assets: List[ImageAsset] = []
|
||||
self._root_folder: Optional[Path] = None
|
||||
self._current_preview_row: int = -1
|
||||
self._start_dir: str = "" # where the folder picker opens
|
||||
self._setup_ui(title, open_text)
|
||||
|
||||
# ── UI construction ────────────────────────────────────────────────
|
||||
@@ -238,10 +240,16 @@ class _AssetPanel(QWidget):
|
||||
|
||||
# ── folder scanning ────────────────────────────────────────────────
|
||||
|
||||
def set_start_dir(self, path: str):
|
||||
"""Set where the folder picker opens (restored last-used folder)."""
|
||||
self._start_dir = path or ""
|
||||
|
||||
def _open_folder(self):
|
||||
folder = QFileDialog.getExistingDirectory(self, "选择文件夹")
|
||||
folder = QFileDialog.getExistingDirectory(self, "选择文件夹", self._start_dir)
|
||||
if not folder:
|
||||
return
|
||||
self._start_dir = folder
|
||||
self.folder_opened.emit(folder)
|
||||
self._root_folder = Path(folder)
|
||||
try:
|
||||
self._assets = scan_image_folder(self._root_folder)
|
||||
@@ -251,6 +259,9 @@ class _AssetPanel(QWidget):
|
||||
logger.info("Loaded %d asset(s) from %s", len(self._assets), folder)
|
||||
self._apply_sort()
|
||||
self.assets_changed.emit(list(self._assets))
|
||||
# Auto-preview the first image so the canvas isn't empty after loading
|
||||
if self._assets:
|
||||
self._select_preview_row(0)
|
||||
|
||||
# ── sorting & list rebuild ─────────────────────────────────────────
|
||||
|
||||
@@ -284,7 +295,12 @@ class _AssetPanel(QWidget):
|
||||
# ── interaction ────────────────────────────────────────────────────
|
||||
|
||||
def _on_item_clicked(self, item: QListWidgetItem):
|
||||
row = self._list.row(item)
|
||||
self._select_preview_row(self._list.row(item))
|
||||
|
||||
def _select_preview_row(self, row: int):
|
||||
"""Highlight the row as the current preview and emit preview_changed."""
|
||||
if not (0 <= row < self._list.count()):
|
||||
return
|
||||
|
||||
# Clear previous preview highlight
|
||||
if 0 <= self._current_preview_row < self._list.count():
|
||||
@@ -295,7 +311,7 @@ class _AssetPanel(QWidget):
|
||||
w.set_preview_selected(False)
|
||||
|
||||
self._current_preview_row = row
|
||||
widget = self._list.itemWidget(item)
|
||||
widget = self._list.itemWidget(self._list.item(row))
|
||||
if widget:
|
||||
widget.set_preview_selected(True)
|
||||
self.preview_changed.emit(widget.asset)
|
||||
@@ -357,6 +373,8 @@ class ImageListPanel(QWidget):
|
||||
print_preview_changed = Signal(object) # ImageAsset | None
|
||||
garments_changed = Signal(list) # List[ImageAsset]
|
||||
prints_changed = Signal(list) # List[ImageAsset]
|
||||
garment_folder_opened = Signal(str) # last-used garment folder
|
||||
print_folder_opened = Signal(str) # last-used print folder
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -378,6 +396,8 @@ class ImageListPanel(QWidget):
|
||||
self._print_panel.preview_changed.connect(self.print_preview_changed)
|
||||
self._garment_panel.assets_changed.connect(self.garments_changed)
|
||||
self._print_panel.assets_changed.connect(self.prints_changed)
|
||||
self._garment_panel.folder_opened.connect(self.garment_folder_opened)
|
||||
self._print_panel.folder_opened.connect(self.print_folder_opened)
|
||||
|
||||
splitter.addWidget(self._garment_panel)
|
||||
splitter.addWidget(self._print_panel)
|
||||
@@ -456,3 +476,9 @@ class ImageListPanel(QWidget):
|
||||
|
||||
def get_prints(self) -> List[ImageAsset]:
|
||||
return self._print_panel.get_assets()
|
||||
|
||||
def set_garment_start_dir(self, path: str):
|
||||
self._garment_panel.set_start_dir(path)
|
||||
|
||||
def set_print_start_dir(self, path: str):
|
||||
self._print_panel.set_start_dir(path)
|
||||
|
||||
@@ -23,7 +23,11 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from core.composer import compose
|
||||
from core.models import BatchMode, ExportOptions, ImageAsset, TransformState
|
||||
from services.file_service import get_output_dir, make_safe_output_path
|
||||
from services.file_service import (
|
||||
get_output_dir,
|
||||
make_safe_output_path,
|
||||
timestamped_run_dir,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -351,7 +355,7 @@ class QueuePanel(QWidget):
|
||||
row = rows[0].row()
|
||||
item = self._model.get_item(row)
|
||||
if item and item.status == "待导出":
|
||||
self._export_item(row, item)
|
||||
self._export_item(row, item, self._new_run_dir())
|
||||
self._update_stats()
|
||||
|
||||
def _toggle_batch(self):
|
||||
@@ -360,24 +364,30 @@ class QueuePanel(QWidget):
|
||||
else:
|
||||
self._start_batch()
|
||||
|
||||
def _new_run_dir(self):
|
||||
"""Timestamped folder shared by all images in one export run."""
|
||||
base = self._export_options.output_dir or str(get_output_dir())
|
||||
return timestamped_run_dir(base)
|
||||
|
||||
def _start_batch(self):
|
||||
self._running = True
|
||||
self._batch_btn.setText("停止")
|
||||
items = self._model._items
|
||||
run_dir = self._new_run_dir() # one timestamp for the whole batch
|
||||
|
||||
for i, item in enumerate(items):
|
||||
if not self._running:
|
||||
break
|
||||
if item.status != "待导出":
|
||||
continue
|
||||
self._export_item(i, item)
|
||||
self._export_item(i, item, run_dir)
|
||||
self._update_stats()
|
||||
QApplication.processEvents()
|
||||
|
||||
self._running = False
|
||||
self._batch_btn.setText("开始批量导出")
|
||||
|
||||
def _export_item(self, row: int, item: QueueItem):
|
||||
def _export_item(self, row: int, item: QueueItem, run_dir):
|
||||
transform = item.transform or self._template_transform
|
||||
if not transform:
|
||||
item.status = "失败"
|
||||
@@ -385,14 +395,9 @@ class QueuePanel(QWidget):
|
||||
self._model.update_row(row)
|
||||
return
|
||||
|
||||
out_dir = (
|
||||
self._export_options.output_dir
|
||||
if self._export_options.output_dir
|
||||
else str(get_output_dir())
|
||||
)
|
||||
fmt = self._export_options.output_format or "PNG"
|
||||
output_path = make_safe_output_path(
|
||||
out_dir, item.garment.path, item.print_asset.path, fmt
|
||||
run_dir, item.garment.path, item.print_asset.path, fmt
|
||||
)
|
||||
|
||||
item.status = "导出中"
|
||||
|
||||
+4
-2
@@ -11,7 +11,7 @@ from core.models import (
|
||||
ImageAsset,
|
||||
TransformState,
|
||||
)
|
||||
from services.file_service import get_output_dir, make_safe_output_path
|
||||
from services.file_service import get_output_dir, make_safe_output_path, timestamped_run_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,7 +38,9 @@ def run_batch(
|
||||
|
||||
results = []
|
||||
export_options = options.export_options
|
||||
output_dir = Path(export_options.output_dir) if export_options.output_dir else get_output_dir()
|
||||
base_dir = Path(export_options.output_dir) if export_options.output_dir else get_output_dir()
|
||||
# One timestamped run folder for the whole batch
|
||||
output_dir = timestamped_run_dir(base_dir)
|
||||
|
||||
logger.info(
|
||||
"Batch started: mode=%s, garments=%d, prints=%d, tasks=%d",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -102,20 +103,32 @@ def scan_image_folder(folder) -> List:
|
||||
# 安全输出文件名
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def timestamped_run_dir(base_dir):
|
||||
"""Return base_dir/<YYYYmmdd_HHMMSS> for grouping one export run.
|
||||
|
||||
Compute this once per export run (a batch, or a single export) and pass the
|
||||
result as the output_dir to make_safe_output_path, so repeated runs land in
|
||||
separate timestamped folders instead of mixing together.
|
||||
"""
|
||||
return Path(base_dir) / datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
|
||||
def make_safe_output_path(output_dir, garment_path, print_path, output_format="PNG"):
|
||||
"""Return an output Path that will not overwrite an existing file.
|
||||
|
||||
Filename pattern : <garment_stem>_<print_stem>.<ext>
|
||||
Outputs are grouped by print into a subfolder named after the print file:
|
||||
<output_dir>/<print_stem>/<garment_stem>.<ext>
|
||||
If that path exists, appends _1, _2, ... until a free slot is found.
|
||||
(The composer creates the subfolder when saving.)
|
||||
"""
|
||||
output_dir = Path(output_dir)
|
||||
ext = ".png" if output_format.upper() == "PNG" else ".jpg"
|
||||
stem = "{}_{}".format(Path(garment_path).stem, Path(print_path).stem)
|
||||
target_dir = Path(output_dir) / Path(print_path).stem
|
||||
stem = Path(garment_path).stem
|
||||
|
||||
candidate = output_dir / (stem + ext)
|
||||
candidate = target_dir / (stem + ext)
|
||||
counter = 1
|
||||
while candidate.exists():
|
||||
candidate = output_dir / ("{}_{}{}".format(stem, counter, ext))
|
||||
candidate = target_dir / ("{}_{}{}".format(stem, counter, ext))
|
||||
counter += 1
|
||||
|
||||
return candidate
|
||||
|
||||
@@ -15,25 +15,25 @@ _TEMPLATES_FILENAME = "templates.json"
|
||||
BUILTIN_TEMPLATES: List[Template] = [
|
||||
Template(
|
||||
name="正方形模板",
|
||||
x_ratio=0.34, y_ratio=0.22,
|
||||
width_ratio=0.32, height_ratio=0.32,
|
||||
x_ratio=0.40, y_ratio=0.40,
|
||||
width_ratio=0.25, height_ratio=0.25,
|
||||
rotation=0.0, type="builtin",
|
||||
),
|
||||
Template(
|
||||
name="纵向长方形模板",
|
||||
x_ratio=0.35, y_ratio=0.16,
|
||||
width_ratio=0.30, height_ratio=0.46,
|
||||
x_ratio=0.435, y_ratio=0.385,
|
||||
width_ratio=0.20, height_ratio=0.30,
|
||||
rotation=0.0, type="builtin",
|
||||
),
|
||||
Template(
|
||||
name="横向长方形模板",
|
||||
x_ratio=0.27, y_ratio=0.26,
|
||||
width_ratio=0.46, height_ratio=0.30,
|
||||
x_ratio=0.385, y_ratio=0.435,
|
||||
width_ratio=0.30, height_ratio=0.20,
|
||||
rotation=0.0, type="builtin",
|
||||
),
|
||||
Template(
|
||||
name="左胸小号模板",
|
||||
x_ratio=0.50, y_ratio=0.22,
|
||||
x_ratio=0.55, y_ratio=0.30,
|
||||
width_ratio=0.16, height_ratio=0.16,
|
||||
rotation=0.0, type="builtin",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user