Coverage for paintmaskanimator/main_window_initial_state.py: 87%
182 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""色塗り前の初期状態の保持と比較表示。``window.initial_state`` が持つ。
3カットフォルダーやCLIPから読み込んだ線画を、セルごとに「色塗り前の初期状態」として
4プロジェクトに保持する。自動塗りは初期状態から黒の主線と色トレス線を取り出して
5部分を分けるので、塗りかけの色に惑わされず精度が上がる。キャンバス下の比較ボタンで、
6今の絵と初期状態を切り替えて見比べられる。
8A collaborator rather than a mixin -- see main_window_export.py for why.
9"""
10from typing import TYPE_CHECKING, List, Tuple, cast
11from pathlib import Path
13from PySide6.QtCore import Qt
14from PySide6.QtGui import QImage
15from PySide6.QtWidgets import QApplication, QCheckBox, QFileDialog, QMessageBox
17from . import clip_animation, cut_folder, preferences
18from .errors import OPERATION_ERRORS, OperationError
19from .frame_scope import FrameScope, resolve_cells
20from .i18n import tr
21from .undo_entries import InitialStateUndo
23if TYPE_CHECKING:
24 from .main_window import MainWindow
26Cell = Tuple[int, int]
29class InitialStateController:
30 """Owned by ``MainWindow`` as ``window.initial_state``."""
32 def __init__(self, window: "MainWindow"):
33 self.window = window
35 def import_line_art_dialog(self) -> int:
36 folder = QFileDialog.getExistingDirectory(
37 self.window, tr("線画カットフォルダーを初期状態として読み込む"), ""
38 )
39 return self.import_line_art(folder) if folder else 0
41 def import_line_art(self, path) -> int:
42 return self._import_line_art(path, from_clip=False)
44 def import_clip_line_art_dialog(self) -> int:
45 path, _ = QFileDialog.getOpenFileName(
46 self.window, tr("CLIPの線画を初期状態として読み込む"), "",
47 tr("CLIP STUDIO PAINT (*.clip);;すべてのファイル (*)"),
48 )
49 return self.import_clip_line_art(path) if path else 0
51 def import_clip_line_art(self, path) -> int:
52 return self._import_line_art(path, from_clip=True)
54 def _import_line_art(self, path, *, from_clip: bool) -> int:
55 """Match layer names/cel numbers, retaining painted pixels and timing.
57 No timesheet is needed. Read and validate every matching image before
58 changing anything, then retain only initial-image references for Undo.
59 """
60 canvas = self.window.canvas
61 title = (tr("CLIPの線画を初期状態として読み込む") if from_clip
62 else tr("線画カットフォルダーを初期状態として読み込む"))
63 status = self.window.statusBar()
64 status.showMessage(tr("線画の初期状態を読み込んでいます…"), 0)
65 QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
66 try:
67 clip_cels: dict[str, dict[int, tuple[str, QImage]]] = {}
68 if from_clip:
69 document = clip_animation.read_clip_animation(path)
70 for source_layer in document.layers:
71 key = source_layer.name.strip().casefold()
72 if key in clip_cels:
73 raise OperationError(tr(
74 "CLIP内のアニメーションフォルダー名が重複しています:{name}"
75 ).format(name=source_layer.name))
76 numbers = clip_animation.cell_sequence_numbers(source_layer)
77 clip_cels[key] = {
78 numbers[tag]: (f"{source_layer.name}/{tag}", image)
79 for tag, image in source_layer.cell_images.items()
80 }
81 file_cels = {}
82 else:
83 file_cels = cut_folder.scan_cut_folder(path).cels
84 loaded = {}
85 cells, archived = [], []
86 missing = 0
88 def match(layer):
89 nonlocal missing
90 if not layer.has_content or layer.is_paper or layer.is_draft:
91 return None
92 name = layer.name.strip()
93 key = name.casefold()
94 if not from_clip and key not in file_cels:
95 key = "".join(
96 "_" if char in cut_folder.INVALID_NAME_CHARS + "/" else char
97 for char in name
98 ).strip(" .").casefold()
99 source = (clip_cels.get(key, {}).get(layer.sequence_number) if from_clip
100 else file_cels.get(key, {}).get(layer.sequence_number))
101 if source is None:
102 missing += 1
103 return None
104 if from_clip:
105 source_name, image = cast(tuple[str, QImage], source)
106 image = image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
107 else:
108 source = cast(Path, source)
109 source_name = Path(source).name
110 if source not in loaded:
111 image, error = canvas._read_image_file(source)
112 if image is None or image.isNull():
113 raise OperationError(tr("{name} を読み込めません。\n{error}").format(
114 name=source_name, error=error,
115 ))
116 loaded[source] = image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
117 image = loaded[source]
118 if image.size() != layer.image.size():
119 raise OperationError(tr(
120 "線画のサイズが現在のセルと一致しません:{name}\n"
121 "線画:{width} × {height}px/現在のセル:{expected_width} × {expected_height}px"
122 ).format(name=source_name, width=image.width(), height=image.height(),
123 expected_width=layer.image.width(), expected_height=layer.image.height()))
124 return image
126 for fi, frame in enumerate(canvas.frames):
127 for li, layer in enumerate(frame.layers):
128 image = match(layer)
129 if image is not None:
130 cells.append((fi, li, image))
131 for (li, number), layer in canvas._sequence_archive.items():
132 image = match(layer)
133 if image is not None:
134 archived.append((li, number, image))
135 except OPERATION_ERRORS as exc:
136 status.clearMessage()
137 # Restore the cursor before a modal error dialog appears.
138 error_text = str(exc)
139 else:
140 error_text = None
141 finally:
142 QApplication.restoreOverrideCursor()
143 if error_text is not None:
144 QMessageBox.critical(self.window, title, tr(
145 "線画を読み込めませんでした。現在の絵と初期状態は変更していません。\n\n{error}"
146 ).format(error=error_text))
147 return 0
148 if not cells and not archived:
149 status.clearMessage()
150 QMessageBox.information(self.window, title, tr(
151 "一致するセルがありません。読み込み元のレイヤー名とセル番号を確認してください。"
152 ))
153 return 0
155 def snapshot(image):
156 return QImage(image) if image is not None else None
158 canvas.push_undo(InitialStateUndo(
159 [(fi, li, snapshot(canvas.frames[fi].layers[li].initial_image)) for fi, li, _ in cells],
160 [(li, number, snapshot(canvas._sequence_archive[(li, number)].initial_image))
161 for li, number, _ in archived],
162 ))
163 for fi, li, image in cells:
164 canvas.frames[fi].layers[li].initial_image = QImage(image)
165 for li, number, image in archived:
166 canvas._sequence_archive[(li, number)].initial_image = QImage(image)
167 canvas._playback_frame_cache.clear()
168 canvas.changed.emit()
169 canvas.update()
170 count = len(cells) + len(archived)
171 status.showMessage(tr(
172 "{count}セルの線画を初期状態として読み込みました。対応する線画がない{missing}セルは変更していません。"
173 ).format(count=count, missing=missing), 8000)
174 return count
176 def cells(self) -> List[Cell]:
177 """全レイヤーの、絵のあるセル(保持はキーフレームへ畳む)。"""
178 canvas = self.window.canvas
179 if not canvas.frames: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 return []
181 layers = range(len(canvas.frames[0].layers))
182 scope = FrameScope.all_frames(canvas, layers=layers)
183 return [
184 (frame, layer) for frame, layer in resolve_cells(canvas, scope)
185 if canvas.frames[frame].layers[layer].has_content
186 ]
188 def count(self, cells: List[Cell]) -> int:
189 """``cells`` のうち、初期状態を保持しているセルの数。"""
190 frames = self.window.canvas.frames
191 return sum(
192 1 for frame, layer in cells if frames[frame].layers[layer].initial_image is not None
193 )
195 def keep(self, confirm: bool = True) -> int:
196 """全レイヤーの今の絵を、色塗り前の初期状態として保持する。保持したセル数を返す。
198 ``confirm`` なら、すでに保持しているセルを上書きしてよいか確かめる。
199 """
200 canvas = self.window.canvas
201 status = self.window.statusBar()
202 cells = self.cells()
203 if not cells: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 status.showMessage(tr("初期状態として保持できるセルがありません。"), 3000)
205 return 0
206 kept = self.count(cells)
207 if kept and confirm: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 answer = QMessageBox.question(
209 self.window,
210 tr("今の絵を初期状態として保持"),
211 tr("{count}セルはすでに色塗り前の初期状態を保持しています。今の絵で"
212 "上書きしますか?\n塗ったあとの絵で上書きすると、色塗り前の状態には"
213 "戻せなくなります。").format(count=kept),
214 )
215 if answer != QMessageBox.StandardButton.Yes:
216 return 0
217 for frame, layer in cells:
218 cell = canvas.frames[frame].layers[layer]
219 # 画素は書き込むまで共有される(コピーの費用はかからない)。
220 cell.initial_image = QImage(cell.image)
221 status.showMessage(
222 tr("{count}セルを色塗り前の初期状態として保持しました。保存するとプロジェクトに"
223 "入ります。").format(count=len(cells)),
224 6000,
225 )
226 return len(cells)
228 def clear(self) -> int:
229 """全レイヤーで保持している初期状態を消す。消したセル数を返す。"""
230 canvas = self.window.canvas
231 status = self.window.statusBar()
232 cells = self.cells()
233 kept = self.count(cells)
234 if not kept:
235 status.showMessage(tr("色塗り前の初期状態を保持していません。"), 3000)
236 return 0
237 answer = QMessageBox.question(
238 self.window,
239 tr("保持した初期状態を消す"),
240 tr("{count}セルで保持している色塗り前の初期状態を消しますか?").format(count=kept),
241 )
242 if answer != QMessageBox.StandardButton.Yes:
243 return 0
244 for frame, layer in cells:
245 canvas.frames[frame].layers[layer].initial_image = None
246 self.set_compare(False)
247 status.showMessage(tr("{count}セルの初期状態を消しました。").format(count=kept), 4000)
248 return kept
250 def offer_after_import(self) -> bool:
251 """カットフォルダーを読み込んだあと、初期状態として保持するか尋ねる。
253 環境設定で「常に保持する」なら尋ねずに保持する。保持したら真。
254 """
255 if preferences.keep_initial_state_on_cut_folder():
256 return bool(self.keep(confirm=False))
257 box = QMessageBox(self.window)
258 box.setIcon(QMessageBox.Icon.Question)
259 box.setWindowTitle(tr("色塗り前の初期状態"))
260 box.setText(tr("読み込んだ絵を、色塗り前の初期状態として保存しますか?"))
261 box.setInformativeText(tr(
262 "保存すると自動色塗りの精度が上がります。自動塗りは初期状態の線で部分を分ける"
263 "ので、塗りかけの色に惑わされません。キャンバス下のボタンで初期状態と見比べる"
264 "こともできます。"
265 ))
266 always = QCheckBox(tr("今後、カットフォルダーの読み込みは常に初期状態として保存する"))
267 always.setToolTip(tr("環境設定の「一般」から外せます。"))
268 box.setCheckBox(always)
269 keep_button = box.addButton(tr("保存する"), QMessageBox.ButtonRole.AcceptRole)
270 box.addButton(tr("保存しない"), QMessageBox.ButtonRole.RejectRole)
271 box.setDefaultButton(keep_button)
272 box.exec()
273 if box.clickedButton() is not keep_button:
274 return False
275 if always.isChecked(): 275 ↛ 277line 275 didn't jump to line 277 because the condition on line 275 was always true
276 preferences.set_keep_initial_state_on_cut_folder(True)
277 return bool(self.keep(confirm=False))
279 # -- 比較表示 -------------------------------------------------------------
280 def set_compare(self, enabled: bool) -> None:
281 """キャンバスに今の絵の代わりに初期状態を表示する(保持していないセルは今の絵)。"""
282 canvas = self.window.canvas
283 enabled = bool(enabled)
284 if enabled and not self.count(self.cells()):
285 enabled = False
286 self.window.statusBar().showMessage(
287 tr("色塗り前の初期状態を保持していないので、比べられません。"), 4000
288 )
289 canvas.show_initial_state = enabled
290 button = getattr(self.window, "compare_initial_button", None)
291 if button is not None and button.isChecked() != enabled:
292 button.blockSignals(True)
293 button.setChecked(enabled)
294 button.blockSignals(False)
295 if enabled:
296 self.window.statusBar().showMessage(
297 tr("色塗り前の初期状態を表示しています。もう一度押すと今の絵に戻ります。"), 4000
298 )
299 canvas.update()