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

1"""色塗り前の初期状態の保持と比較表示。``window.initial_state`` が持つ。 

2 

3カットフォルダーやCLIPから読み込んだ線画を、セルごとに「色塗り前の初期状態」として 

4プロジェクトに保持する。自動塗りは初期状態から黒の主線と色トレス線を取り出して 

5部分を分けるので、塗りかけの色に惑わされず精度が上がる。キャンバス下の比較ボタンで、 

6今の絵と初期状態を切り替えて見比べられる。 

7 

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 

12 

13from PySide6.QtCore import Qt 

14from PySide6.QtGui import QImage 

15from PySide6.QtWidgets import QApplication, QCheckBox, QFileDialog, QMessageBox 

16 

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 

22 

23if TYPE_CHECKING: 

24 from .main_window import MainWindow 

25 

26Cell = Tuple[int, int] 

27 

28 

29class InitialStateController: 

30 """Owned by ``MainWindow`` as ``window.initial_state``.""" 

31 

32 def __init__(self, window: "MainWindow"): 

33 self.window = window 

34 

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 

40 

41 def import_line_art(self, path) -> int: 

42 return self._import_line_art(path, from_clip=False) 

43 

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 

50 

51 def import_clip_line_art(self, path) -> int: 

52 return self._import_line_art(path, from_clip=True) 

53 

54 def _import_line_art(self, path, *, from_clip: bool) -> int: 

55 """Match layer names/cel numbers, retaining painted pixels and timing. 

56 

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 

87 

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 

125 

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 

154 

155 def snapshot(image): 

156 return QImage(image) if image is not None else None 

157 

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 

175 

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 ] 

187 

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 ) 

194 

195 def keep(self, confirm: bool = True) -> int: 

196 """全レイヤーの今の絵を、色塗り前の初期状態として保持する。保持したセル数を返す。 

197 

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) 

227 

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 

249 

250 def offer_after_import(self) -> bool: 

251 """カットフォルダーを読み込んだあと、初期状態として保持するか尋ねる。 

252 

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)) 

278 

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()