Coverage for paintmaskanimator/main_window_import.py: 60%

556 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""PSD / XDTS / CLIP import; owned by ``MainWindow`` as ``window.importer``. 

2 

3Drives the file dialogs and the parsing that brings external PSD layers, XDTS 

4timesheets and CLIP STUDIO animations into the current project. 

5 

6A collaborator rather than a mixin -- see ``main_window_export.py`` for why. 

7""" 

8from typing import TYPE_CHECKING, Any, cast 

9 

10from pathlib import Path 

11from PySide6.QtCore import QEventLoop, QObject, QThread, QTimer, Qt, Signal, Slot 

12from PySide6.QtGui import QImage, QPainter 

13from PySide6.QtWidgets import ( 

14 QApplication, QDialog, QFileDialog, QLabel, QMessageBox, QProgressBar, QVBoxLayout, 

15) 

16from .i18n import tr 

17from .optional_deps import PILImage, PSDImage 

18from .constants import ( 

19 MAX_IMAGE_DIMENSION, 

20 MAX_PROJECT_LAYERS, 

21 MAX_PROJECT_LAYER_CELLS, 

22 MAX_SINGLE_IMAGE_PIXELS, 

23) 

24import sqlite3 

25import traceback 

26from . import constants 

27from .canvas import PaintCanvas 

28from .clip_animation import ( 

29 ClipAnimationDocument, 

30 ClipAnimationLayer, 

31 ClipCelKey, 

32 ClipImportError, 

33 build_pma_frames, 

34 cell_sequence_numbers, 

35 read_clip_animation, 

36) 

37from . import cut_folder, timesheet_file 

38from .errors import OPERATION_ERRORS as _OPERATION_ERRORS, OperationError 

39from .models import Layer 

40from .utils import blank_image 

41from .logging_setup import get_logger 

42 

43if TYPE_CHECKING: 

44 from .main_window import MainWindow 

45 

46log = get_logger(__name__) 

47 

48 

49class _ClipReadWorker(QThread): 

50 progress = Signal(int, int, str) 

51 

52 def __init__(self, source, reader): 

53 super().__init__() 

54 self.source = source 

55 self.reader = reader 

56 self.result: ClipAnimationDocument | None = None 

57 self.error: ClipImportError | None = None 

58 

59 def run(self): 

60 try: 

61 # The native reader is thread-affine: create, use and destroy it here. 

62 self.result = self.reader(self.source, progress=self.progress.emit) 

63 except BaseException as exc: 

64 # Log only text: queued/capturing logging handlers may otherwise 

65 # retain exc_info and destroy native locals on a different thread. 

66 log.error("CLIP reader worker failed:\n%s", traceback.format_exc()) 

67 # Never transfer a traceback retaining the thread-affine native 

68 # reader to the UI thread, even when decoding fails halfway. 

69 self.error = ClipImportError(str(exc)) 

70 

71 

72class _ClipImportDialog(QDialog): 

73 def __init__(self, parent): 

74 super().__init__(parent) 

75 self.setObjectName("clipImportProgressDialog") 

76 self.setWindowTitle(tr("CLIP STUDIOアニメーション読み込み")) 

77 self.setWindowModality(Qt.WindowModality.WindowModal) 

78 self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) 

79 self.setMinimumWidth(420) 

80 self.label = QLabel(tr("CLIP STUDIOアニメーションを解析しています…"), self) 

81 self.label.setWordWrap(True) 

82 self.bar = QProgressBar(self) 

83 self.bar.setObjectName("clipImportProgress") 

84 self.bar.setRange(0, 0) 

85 layout = QVBoxLayout(self) 

86 layout.addWidget(self.label) 

87 layout.addWidget(self.bar) 

88 

89 def reject(self): 

90 # Escape must not close the modal guard while the worker is running. 

91 pass 

92 

93 def closeEvent(self, event): 

94 event.ignore() 

95 

96 

97class _ClipProgressDisplay(QObject): 

98 def __init__(self, dialog): 

99 super().__init__(dialog) 

100 self.dialog = dialog 

101 self.bar = dialog.bar 

102 

103 @Slot(int, int, str) 

104 def update(self, completed, total, label): 

105 if total: 

106 self.bar.setRange(0, total) 

107 self.bar.setValue(completed) 

108 self.bar.setFormat("%v / %m (%p%)") 

109 else: 

110 self.bar.setRange(0, 0) 

111 self.dialog.label.setText(label) 

112 

113 

114class ImportController: 

115 """Owned by ``MainWindow`` as ``window.import_``. 

116 

117 A collaborator rather than a mixin -- see main_window_export.py for why. 

118 """ 

119 

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

121 self.window = window 

122 

123 def clip_animation_dialog(self): 

124 path, _ = QFileDialog.getOpenFileName( 

125 self.window, 

126 tr("CLIP STUDIOアニメーションを読み込む"), 

127 "", 

128 tr("CLIP STUDIO PAINT (*.clip);;すべてのファイル (*)"), 

129 ) 

130 if path: 

131 return self.clip_animation(path) 

132 return False 

133 

134 def _clip_import_needs_confirmation(self): 

135 if self.window.current_project_path: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 return True 

137 if len(self.window.canvas.frames) != 1: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 return True 

139 layers = self.window.canvas.frames[0].layers if self.window.canvas.frames else [] 

140 return ( 

141 len(layers) != 1 

142 or any(layer.has_content or layer.is_blank_key for layer in layers) 

143 ) 

144 

145 def _confirm_replace_for_clip_import(self): 

146 return self._confirm_replace_document( 

147 tr("CLIP STUDIOアニメーションを読み込む"), 

148 tr("現在のキャンバスをCLIP STUDIOアニメーションで置き換えます。\n" 

149 "先に現在のプロジェクトを保存しますか?"), 

150 ) 

151 

152 def _confirm_replace_document(self, title, text): 

153 if not self._clip_import_needs_confirmation(): 153 ↛ 155line 153 didn't jump to line 155 because the condition on line 153 was always true

154 return True 

155 dialog = QMessageBox(self.window) 

156 dialog.setIcon(QMessageBox.Icon.Question) 

157 dialog.setWindowTitle(title) 

158 dialog.setText(text) 

159 save_button = dialog.addButton( 

160 tr("保存する"), QMessageBox.ButtonRole.AcceptRole 

161 ) 

162 discard_button = dialog.addButton( 

163 tr("保存せず読み込む"), QMessageBox.ButtonRole.DestructiveRole 

164 ) 

165 cancel_button = dialog.addButton( 

166 tr("キャンセル"), QMessageBox.ButtonRole.RejectRole 

167 ) 

168 dialog.setDefaultButton(save_button) 

169 dialog.exec() 

170 clicked = dialog.clickedButton() 

171 if clicked is cancel_button or clicked is None: 

172 return False 

173 if clicked is save_button: 

174 return bool(self.window.project.save()) 

175 return clicked is discard_button 

176 

177 def _read_clip_with_progress(self, source): 

178 dialog = _ClipImportDialog(self.window) 

179 self._clip_import_active = True 

180 

181 display = _ClipProgressDisplay(dialog) 

182 worker = _ClipReadWorker(source, read_clip_animation) 

183 loop = QEventLoop() 

184 worker.progress.connect(display.update) 

185 worker.finished.connect(loop.quit, Qt.ConnectionType.QueuedConnection) 

186 try: 

187 if self.window.canvas._playback_active: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true

188 self.window.timeline.play.setChecked(False) 

189 self.window.play(False) 

190 dialog.show() 

191 worker.start() 

192 # Keep Qt's normal event loop alive while parsing/compositing runs 

193 # off the UI thread. The modal popup blocks document edits. 

194 loop.exec() 

195 worker.wait() 

196 if worker.error is not None: 

197 raise worker.error 

198 if worker.result is None: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 raise ClipImportError(tr("CLIP STUDIOアニメーションを読み込めませんでした。")) 

200 return worker.result 

201 finally: 

202 worker.wait() 

203 worker.deleteLater() 

204 display.deleteLater() 

205 dialog.done(0) 

206 dialog.deleteLater() 

207 self._clip_import_active = False 

208 

209 def clip_animation(self, path, confirm_replace=True): 

210 if getattr(self, "_clip_import_active", False): 

211 return False 

212 source = Path(path) 

213 if source.suffix.lower() != ".clip": 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true

214 return False 

215 self.window.statusBar().showMessage( 

216 tr("CLIP STUDIOアニメーションを解析しています:{name}").format(name=source.name), 

217 0, 

218 ) 

219 QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) 

220 try: 

221 parsed = self._read_clip_with_progress(source) 

222 except (ClipImportError, OSError, ValueError, sqlite3.Error) as exc: 

223 log.error("CLIP STUDIO animation import failed: %s", exc, exc_info=True) 

224 self.window.statusBar().clearMessage() 

225 QMessageBox.critical( 

226 self.window, 

227 tr("CLIP STUDIOアニメーション読み込み"), 

228 tr("読み込めませんでした。現在のドキュメントは変更されていません。\n\n{exc}").format(exc=exc), 

229 ) 

230 return False 

231 finally: 

232 QApplication.restoreOverrideCursor() 

233 if confirm_replace and not self._confirm_replace_for_clip_import(): 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 self.window.statusBar().clearMessage() 

235 return False 

236 if not self._replace_document( 

237 parsed, 

238 title=tr("CLIP STUDIOアニメーション読み込み"), 

239 source_metadata=parsed.source_metadata(), 

240 ): 

241 return False 

242 

243 fps_note = "" 

244 if abs(float(parsed.fps) - self.window.timeline.fps.value()) > 1e-6: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 fps_note = ( 

246 tr("(元FPS {fps:g}、PMA表示 {value} fps)").format(fps=parsed.fps, value=self.window.timeline.fps.value()) 

247 ) 

248 archive_note = "" 

249 if self.window.canvas._sequence_archive: 

250 archive_note = ( 

251 tr(" 未配置セル{len}枚は連番に保持しました。").format(len=len(self.window.canvas._sequence_archive)) 

252 ) 

253 # 読み込んだ直後の絵は定義上「色塗り前」なので、尋ねずに初期状態として保持する 

254 # (自動塗りの精度が上がる)。keep() 自身のステータス表示はこのあと上書きする。 

255 initial_note = "" 

256 kept = self.window.initial_state.keep(confirm=False) 

257 if kept: 257 ↛ 259line 257 didn't jump to line 259 because the condition on line 257 was always true

258 initial_note = tr(" {count}セルを色塗り前の初期状態として保持しました。").format(count=kept) 

259 self.window.statusBar().showMessage( 

260 tr("CLIP STUDIOから{count}フォルダー・{count2}フレームを読み込みました。{note}{note2}{note3}").format(count=parsed.folder_count, count2=parsed.frame_count, note=archive_note, note2=fps_note, note3=initial_note), 

261 6000, 

262 ) 

263 return True 

264 

265 def cut_folder_dialog(self): 

266 folder = QFileDialog.getExistingDirectory( 

267 self.window, tr("カットフォルダーを開く"), "" 

268 ) 

269 if folder: 

270 return self.cut_folder(folder) 

271 return False 

272 

273 def cut_folder(self, path, confirm_replace=True): 

274 """カットフォルダー(セル画像+XDTS/TDTS)を開いて置き換える。""" 

275 title = tr("カットフォルダーを開く") 

276 root = Path(path) 

277 self.window.statusBar().showMessage( 

278 tr("カットフォルダーを読み込んでいます:{name}").format(name=root.name), 0 

279 ) 

280 QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) 

281 try: 

282 parsed, notes = self.read_cut_folder(root) 

283 except (OperationError, ClipImportError, OSError, ValueError) as exc: 

284 log.error("cut folder import failed: %s", exc, exc_info=True) 

285 self.window.statusBar().clearMessage() 

286 QMessageBox.critical( 

287 self.window, 

288 title, 

289 tr("読み込めませんでした。現在のドキュメントは変更されていません。\n\n{exc}").format(exc=exc), 

290 ) 

291 return False 

292 finally: 

293 QApplication.restoreOverrideCursor() 

294 if confirm_replace and not self._confirm_replace_document( 294 ↛ 299line 294 didn't jump to line 299 because the condition on line 294 was never true

295 title, 

296 tr("現在のキャンバスをカットフォルダーの内容で置き換えます。\n" 

297 "先に現在のプロジェクトを保存しますか?"), 

298 ): 

299 self.window.statusBar().clearMessage() 

300 return False 

301 if not self._replace_document(parsed, title=title, source_metadata=None): 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true

302 return False 

303 if self.window.canvas._sequence_archive: 

304 notes.append( 

305 tr("タイムシートで使われていないセル{count}枚は連番に保持しました。").format( 

306 count=len(self.window.canvas._sequence_archive) 

307 ) 

308 ) 

309 message = tr("「{name}」から{count}列・{frames}フレームを読み込みました。").format( 

310 name=root.name, count=parsed.folder_count, frames=parsed.frame_count 

311 ) 

312 self.window.statusBar().showMessage(message, 6000) 

313 if notes: 

314 QMessageBox.information( 

315 self.window, title, message + "\n\n" + "\n".join(notes) 

316 ) 

317 # 読み込んだままの絵を、色塗り前の初期状態として保持するか(自動塗りの精度が上がる)。 

318 self.window.initial_state.offer_after_import() 

319 return True 

320 

321 def read_cut_folder(self, root): 

322 """カットフォルダーを解析し、``(document, notes)`` を返す。 

323 

324 列(A・B…)はタイムシートの順に並べ、タイムシートにないセルの 

325 フォルダーは後ろに足して、そのセルを連番保管セルにする。 

326 ``notes`` は読み込めたが利用者に知らせたいこと。 

327 """ 

328 root = Path(root) 

329 contents = cut_folder.scan_cut_folder(root) 

330 if not contents.timesheets: 

331 raise OperationError( 

332 tr("タイムシート(.xdts/.tdts)が見つかりません。") 

333 ) 

334 notes = [] 

335 sheet_path = contents.timesheets[0] 

336 if len(contents.timesheets) > 1: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 notes.append( 

338 tr("タイムシートが複数あるため「{name}」を使いました。").format( 

339 name=sheet_path.relative_to(root).as_posix() 

340 ) 

341 ) 

342 sheet = self._parse_xdts_timesheet(sheet_path.read_text(encoding="utf-8-sig")) 

343 duration = int(sheet["end_frame"]) + 1 

344 columns = [ 

345 track for track in sheet["tracks"] if track.get("group") == "CELL" 

346 ] 

347 if not columns: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true

348 raise OperationError(tr("タイムシートにセルの列がありません。")) 

349 

350 size = None 

351 resized = 0 

352 

353 def load(path): 

354 nonlocal size, resized 

355 image, error = self.window.canvas._read_image_file(path) 

356 if image is None or image.isNull(): 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true

357 raise OperationError( 

358 tr("{name} を読み込めません。\n{error}").format( 

359 name=Path(path).relative_to(root).as_posix(), error=error 

360 ) 

361 ) 

362 image = image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied) 

363 if size is None: 

364 size = image.size() 

365 elif image.size() != size: 365 ↛ 367line 365 didn't jump to line 367 because the condition on line 365 was never true

366 # セルの大きさが揃っていなければ、最初のセルの大きさの中央に置く。 

367 resized += 1 

368 placed = QImage(size, QImage.Format.Format_ARGB32_Premultiplied) 

369 placed.fill(Qt.GlobalColor.transparent) 

370 painter = QPainter(placed) 

371 painter.drawImage( 

372 (size.width() - image.width()) // 2, 

373 (size.height() - image.height()) // 2, 

374 image, 

375 ) 

376 painter.end() 

377 image = placed 

378 return image 

379 

380 def column_images(drawings): 

381 return { 

382 str(number): load(path) 

383 for number, path in sorted(drawings.items()) 

384 } 

385 

386 layers = [] 

387 used = set() 

388 missing = [] 

389 for column in columns: 

390 name = str(column.get("name", "")).strip() 

391 key = name.casefold() 

392 if key not in contents.cels: 392 ↛ 394line 392 didn't jump to line 394 because the condition on line 392 was never true

393 # 書き出し時にフォルダー名で使えない文字は「_」へ置き換えている。 

394 key = "".join( 

395 "_" if character in cut_folder.INVALID_NAME_CHARS + "/" else character 

396 for character in name 

397 ).strip(" .").casefold() 

398 used.add(key) 

399 images = column_images(contents.cels.get(key, {})) 

400 keys = [] 

401 for frame, number in cut_folder.exposure_keys(column.get("states", [])): 

402 tag = "" if number is None else str(number) 

403 if tag and tag not in images: 

404 missing.append(f"{name}{number}") 

405 tag = "" 

406 keys.append(ClipCelKey(frame, tag)) 

407 layers.append(ClipAnimationLayer(name, tuple(keys), images)) 

408 for key, drawings in contents.cels.items(): 

409 if key not in used: 

410 layers.append( 

411 ClipAnimationLayer(contents.names[key], (), column_images(drawings)) 

412 ) 

413 if size is None: 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true

414 raise OperationError(tr("セルの画像(PNG/TGA)が見つかりません。")) 

415 if size.width() > MAX_IMAGE_DIMENSION or size.height() > MAX_IMAGE_DIMENSION: 415 ↛ 416line 415 didn't jump to line 416 because the condition on line 415 was never true

416 raise OperationError(tr("画像サイズが上限を超えています。\n{width} × {height}px").format( 

417 width=size.width(), height=size.height() 

418 )) 

419 

420 width, height = size.width(), size.height() 

421 frames = build_pma_frames(width, height, 0, duration - 1, tuple(layers)) 

422 document = ClipAnimationDocument( 

423 source_name=root.name, 

424 timeline_name=sheet_path.stem, 

425 width=width, 

426 height=height, 

427 fps=float(self.window.timeline.fps.value()), 

428 start_frame=0, 

429 end_frame=duration - 1, 

430 layers=tuple(layers), 

431 frames=frames, 

432 ) 

433 if missing: 

434 shown = "、".join(missing[:10]) + ("…" if len(missing) > 10 else "") 

435 notes.append( 

436 tr("画像が見つからないセルは空セルにしました:{cells}").format(cells=shown) 

437 ) 

438 if resized: 438 ↛ 439line 438 didn't jump to line 439 because the condition on line 438 was never true

439 notes.append( 

440 tr("大きさの違うセル{count}枚は、中央に置いて大きさを揃えました。").format( 

441 count=resized 

442 ) 

443 ) 

444 return document, notes 

445 

446 def _replace_document(self, parsed, *, title, source_metadata): 

447 """現在のドキュメントを読み込んだアニメーションで置き換える。 

448 

449 CLIP STUDIO とカットフォルダーの読み込みで共通。失敗したら元へ戻す。 

450 """ 

451 old_state = { 

452 "width": constants.CANVAS_WIDTH, 

453 "height": constants.CANVAS_HEIGHT, 

454 "frames": self.window.canvas.frames, 

455 "current_frame": self.window.canvas.current_frame, 

456 "active_layer_index": self.window.canvas.active_layer_index, 

457 "timeline_mode": self.window.canvas.timeline_mode, 

458 "fps": self.window.timeline.fps.value(), 

459 "project_path": self.window.current_project_path, 

460 "clip_metadata": getattr( 

461 self.window.canvas, "clip_studio_source_metadata", None 

462 ), 

463 "palette": self.window.palette.serialize_categories(), 

464 "undo": list(self.window.canvas.undo_stack), 

465 "redo": list(self.window.canvas.redo_stack), 

466 "branches": list(self.window.canvas.history_branches), 

467 "sequence_archive": self.window.canvas._sequence_archive, 

468 "sequence_bank": self.window.canvas._sequence_source_bank, 

469 "sequence_bank_index": self.window.canvas._sequence_source_bank_layer_index, 

470 "sequence_bank_name": self.window.canvas._sequence_source_bank_layer_name, 

471 } 

472 try: 

473 if self.window.canvas._playback_active: 473 ↛ 474line 473 didn't jump to line 474 because the condition on line 473 was never true

474 self.window.timeline.play.setChecked(False) 

475 self.window.play(False) 

476 self.window.tween.cancel_transform_or_tween() 

477 self.window.canvas.clear_selection() 

478 constants.CANVAS_WIDTH = int(parsed.width) 

479 constants.CANVAS_HEIGHT = int(parsed.height) 

480 self.window.canvas.frames = list(parsed.frames) 

481 self.window.canvas.current_frame = 0 

482 self.window.canvas.active_layer_index = 0 

483 self.window.canvas.timeline_mode = "sheet" 

484 self.window.timeline.set_timeline_mode("sheet") 

485 self.window.timeline.fps.setValue( 

486 max(1, min(60, int(round(parsed.fps)))) 

487 ) 

488 self.window.canvas.clip_studio_source_metadata = source_metadata 

489 self.window.canvas._sequence_source_bank = [] 

490 self.window.canvas._sequence_source_bank_layer_index = -1 

491 self.window.canvas._sequence_source_bank_layer_name = "" 

492 sequence_archive = {} 

493 for layer_index, source_layer in enumerate(parsed.layers): 

494 numbers = cell_sequence_numbers(source_layer) 

495 placed_numbers = { 

496 int(frame.layers[layer_index].sequence_number) 

497 for frame in self.window.canvas.frames 

498 if ( 

499 frame.layers[layer_index].has_content 

500 and frame.layers[layer_index].sequence_number is not None 

501 ) 

502 } 

503 for tag, image in source_layer.cell_images.items(): 

504 number = numbers[tag] 

505 if number in placed_numbers: 

506 continue 

507 stripped_tag = str(tag).strip() 

508 normalized_number = stripped_tag.lstrip("0") or "0" 

509 sequence_archive[(layer_index, number)] = Layer( 

510 source_layer.name, 

511 QImage(image), 

512 has_content=True, 

513 exposure=1, 

514 sequence_number=number, 

515 cell_name=( 

516 None 

517 if stripped_tag.isdecimal() 

518 and normalized_number == str(number) 

519 else str(tag) 

520 ), 

521 ) 

522 self.window.canvas._sequence_archive = sequence_archive 

523 self.window.timeline.sequence_archive = self.window.canvas._sequence_archive 

524 self.window.canvas.undo_stack.clear() 

525 self.window.canvas.redo_stack.clear() 

526 self.window.canvas.clear_history_branches() 

527 self.window.canvas._onion_cache.clear() 

528 self.window.canvas._color_filter_cache.clear() 

529 self.window.canvas._color_index_cache.clear() 

530 self.window.canvas._silhouette_cache.clear() 

531 self.window._used_color_cache.clear() 

532 self.window.palette.clear_categories() 

533 self.window.current_project_path = None 

534 self.window.project.update_title() 

535 self.window.refresh_ui() 

536 self.window.used_color.schedule_refresh() 

537 QTimer.singleShot(0, self.window.fit_canvas) 

538 except _OPERATION_ERRORS as exc: 

539 constants.CANVAS_WIDTH = old_state["width"] 

540 constants.CANVAS_HEIGHT = old_state["height"] 

541 self.window.canvas.frames = old_state["frames"] 

542 self.window.canvas.current_frame = old_state["current_frame"] 

543 self.window.canvas.active_layer_index = old_state["active_layer_index"] 

544 self.window.canvas.timeline_mode = old_state["timeline_mode"] 

545 self.window.timeline.set_timeline_mode(old_state["timeline_mode"]) 

546 self.window.timeline.fps.setValue(old_state["fps"]) 

547 self.window.current_project_path = old_state["project_path"] 

548 self.window.canvas.clip_studio_source_metadata = old_state["clip_metadata"] 

549 self.window.palette.restore_categories(old_state["palette"]) 

550 self.window.canvas.undo_stack[:] = old_state["undo"] 

551 self.window.canvas.redo_stack[:] = old_state["redo"] 

552 self.window.canvas.history_branches[:] = old_state["branches"] 

553 self.window.canvas._sequence_archive = old_state["sequence_archive"] 

554 self.window.timeline.sequence_archive = self.window.canvas._sequence_archive 

555 self.window.canvas._sequence_source_bank = old_state["sequence_bank"] 

556 self.window.canvas._sequence_source_bank_layer_index = old_state[ 

557 "sequence_bank_index" 

558 ] 

559 self.window.canvas._sequence_source_bank_layer_name = old_state[ 

560 "sequence_bank_name" 

561 ] 

562 self.window.project.update_title() 

563 self.window.refresh_ui() 

564 log.error("import apply failed and rolled back: %s", exc, exc_info=True) 

565 QMessageBox.critical( 

566 self.window, 

567 title, 

568 tr("読み込み結果を反映できませんでした。現在のドキュメントは元の状態へ戻しました。\n\n{exc}").format(exc=exc), 

569 ) 

570 return False 

571 return True 

572 

573 @staticmethod 

574 def _parse_xdts_timesheet(raw_text): 

575 time_table = timesheet_file.load_time_table(raw_text) 

576 duration = max(1, int(time_table.get("duration", 1))) 

577 headers = {} 

578 for item in time_table.get("timeTableHeaders", []): 

579 if not isinstance(item, dict): 579 ↛ 580line 579 didn't jump to line 580 because the condition on line 579 was never true

580 continue 

581 field_id = int(item.get("fieldId", -1)) 

582 headers.setdefault(field_id, list(item.get("names", []))) 

583 

584 def column_group(field_id, name): 

585 if field_id == 3: 585 ↛ 586line 585 didn't jump to line 586 because the condition on line 585 was never true

586 return "ACTION" 

587 if field_id == 5: 587 ↛ 588line 587 didn't jump to line 588 because the condition on line 587 was never true

588 return "CAM" 

589 normalized = str(name).strip().casefold() 

590 action_words = ("memo", "action", "act", "camera", "cam", "pan") 

591 if ( 591 ↛ 596line 591 didn't jump to line 596 because the condition on line 591 was never true

592 normalized.startswith(("_", "◆", "-")) 

593 or normalized in ("ts", "タイムシート") 

594 or any(word in normalized for word in action_words) 

595 ): 

596 return "ACTION" 

597 return "CELL" 

598 

599 symbol_labels = { 

600 "SYMBOL_HYPHEN": "|", 

601 "SYMBOL_NULL_CELL": "×", 

602 "SYMBOL_TICK_1": "○", 

603 "SYMBOL_TICK_2": "●", 

604 } 

605 sheet_columns = [] 

606 parsed_tracks = [] 

607 supported_fields = {0, 3, 5} 

608 for field_index, field in enumerate(time_table.get("fields", [])): 

609 if not isinstance(field, dict): 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true

610 continue 

611 field_id = int(field.get("fieldId", -1)) 

612 if field_id not in supported_fields: 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true

613 continue 

614 names = headers.get(field_id, []) 

615 action_boundary = next( 

616 ( 

617 index for index, header_name in enumerate(names[:-1]) 

618 if "memo" in str(header_name).strip().casefold() 

619 or "メモ" in str(header_name).strip() 

620 ), 

621 None, 

622 ) if field_id == 0 else None 

623 for track in sorted( 

624 field.get("tracks", []), 

625 key=lambda item: int(item.get("trackNo", 0)), 

626 ): 

627 if not isinstance(track, dict): 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true

628 continue 

629 track_no = int(track.get("trackNo", 0)) 

630 default_names = { 

631 0: "セル欄", 

632 3: "アクション", 

633 5: "カメラ", 

634 } 

635 name = ( 

636 str(names[track_no]).strip() 

637 if 0 <= track_no < len(names) 

638 and str(names[track_no]).strip() 

639 else f"{default_names[field_id]} {track_no + 1}" 

640 ) 

641 entries = { 

642 int(item.get("frame", 0)): item 

643 for item in track.get("frames", []) 

644 if isinstance(item, dict) 

645 } 

646 states = [] 

647 display_values = [] 

648 current_state = None 

649 blank_label_count = 0 

650 for frame in range(duration): 

651 item = entries.get(frame) 

652 values = [] 

653 if item is not None: 

654 instruction = next( 

655 ( 

656 data for data in item.get("data", []) 

657 if isinstance(data, dict) 

658 and int(data.get("id", -1)) == 0 

659 ), 

660 None, 

661 ) 

662 if instruction is not None: 662 ↛ 669line 662 didn't jump to line 669 because the condition on line 662 was always true

663 raw_values = instruction.get("values", []) 

664 values = ( 

665 list(raw_values) 

666 if isinstance(raw_values, list) 

667 else [raw_values] 

668 ) 

669 tokens = [str(value).strip() for value in values] 

670 token = tokens[0] if tokens else None 

671 if token in symbol_labels: 

672 display = symbol_labels[token] 

673 elif field_id == 3 and tokens: 673 ↛ 674line 673 didn't jump to line 674 because the condition on line 673 was never true

674 dialogue = [value for value in tokens[:2] if value] 

675 display = ":".join(dialogue) 

676 elif tokens: 

677 display = " / ".join(value for value in tokens if value) 

678 else: 

679 display = "" 

680 

681 if field_id == 0: 681 ↛ 697line 681 didn't jump to line 697 because the condition on line 681 was always true

682 if token in (None, "SYMBOL_HYPHEN"): 

683 if current_state is not None: 

684 display = "|" 

685 elif token == "SYMBOL_NULL_CELL": 

686 current_state = None 

687 elif token in ("SYMBOL_TICK_1", "SYMBOL_TICK_2"): 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true

688 current_state = None 

689 blank_label_count += 1 

690 elif token.isdecimal(): 690 ↛ 694line 690 didn't jump to line 694 because the condition on line 690 was always true

691 current_state = max(1, int(token)) 

692 display = str(current_state) 

693 else: 

694 current_state = None 

695 blank_label_count += 1 

696 states.append(current_state) 

697 display_values.append(display) 

698 

699 group = column_group(field_id, name) 

700 if ( 700 ↛ 705line 700 didn't jump to line 705 because the condition on line 700 was never true

701 field_id == 0 

702 and action_boundary is not None 

703 and track_no <= action_boundary 

704 ): 

705 group = "ACTION" 

706 column = { 

707 "uid": f"{field_id}:{field_index}:{track_no}", 

708 "field_id": field_id, 

709 "track_no": track_no, 

710 "name": name, 

711 "group": group, 

712 "start_frame": 0, 

713 "end_frame": duration - 1, 

714 "states": states, 

715 "display_values": display_values, 

716 "blank_label_count": blank_label_count, 

717 "bindable": field_id == 0 and group == "CELL", 

718 } 

719 sheet_columns.append(column) 

720 if field_id == 0: 720 ↛ 623line 720 didn't jump to line 623 because the condition on line 720 was always true

721 parsed_tracks.append(dict(column)) 

722 

723 if not parsed_tracks: 723 ↛ 724line 723 didn't jump to line 724 because the condition on line 723 was never true

724 raise OperationError(tr("XDTSにセル欄(fieldId 0)がありません。")) 

725 group_order = {"ACTION": 0, "CELL": 1, "CAM": 2} 

726 sheet_columns.sort( 

727 key=lambda item: ( 

728 group_order.get(str(item.get("group", "CELL")), 9), 

729 int(item.get("field_id", 0)), 

730 int(item.get("track_no", 0)), 

731 ) 

732 ) 

733 primary = parsed_tracks[0] 

734 return { 

735 "format": timesheet_file.format_label(raw_text), 

736 "fps": None, 

737 "start_frame": 0, 

738 "end_frame": duration - 1, 

739 "states": list(primary["states"]), 

740 "blank_label_count": int(primary["blank_label_count"]), 

741 "tracks": parsed_tracks, 

742 "sheet_columns": sheet_columns, 

743 } 

744 

745 def apply_xdts_layer_bindings(self, parsed, confirm=True): 

746 columns = { 

747 str(column.get("uid", "")): column 

748 for column in parsed.get("sheet_columns", []) 

749 if isinstance(column, dict) 

750 } 

751 bindings = dict(parsed.get("layer_bindings", {})) 

752 start_frame = int(parsed.get("start_frame", 0)) 

753 end_frame = int(parsed.get("end_frame", -1)) 

754 duration = end_frame - start_frame + 1 

755 if duration <= 0 or not bindings: 

756 raise OperationError(tr("読み込むCELLとレイヤーの紐づけがありません。")) 

757 if not self.window.canvas.frames: 

758 raise OperationError(tr("タイムラインがありません。")) 

759 current_frame = max( 

760 0, 

761 min(int(self.window.canvas.current_frame), len(self.window.canvas.frames) - 1), 

762 ) 

763 current_layers = self.window.canvas.frames[current_frame].layers 

764 prepared = [] 

765 for uid, layer_index_value in bindings.items(): 

766 column = columns.get(str(uid)) 

767 if column is None or not bool(column.get("bindable", False)): 

768 continue 

769 states = list(column.get("states", [])) 

770 if len(states) != duration: 

771 raise OperationError( 

772 tr("CELL「{get}」のフレーム数が不正です。").format(get=column.get('name', '')) 

773 ) 

774 layer_index = int(layer_index_value) 

775 if not (0 <= layer_index < len(current_layers)): 

776 raise OperationError( 

777 tr("CELL「{get}」の紐づけ先レイヤーがありません。").format(get=column.get('name', '')) 

778 ) 

779 source_bank = self.window.time_remap._time_remap_source_bank(layer_index) 

780 if not source_bank: 

781 raise OperationError( 

782 tr("レイヤー「{name}」に連番画像がありません。").format(name=current_layers[layer_index].name) 

783 ) 

784 referenced = sorted({ 

785 int(state) for state in states if state is not None 

786 }) 

787 missing = self.window.time_remap.missing_numbers(source_bank, referenced) 

788 if missing: 

789 preview = ", ".join(str(value) for value in missing[:12]) 

790 if len(missing) > 12: 

791 preview += "…" 

792 raise OperationError( 

793 tr("CELL「{get}」は存在しない絵番号を参照しています(レイヤー画像 {len}枚)。\n{preview}").format(get=column.get('name', ''), len=len(source_bank), preview=preview) 

794 ) 

795 prepared.append(( 

796 column, 

797 layer_index, 

798 states, 

799 source_bank, 

800 )) 

801 if not prepared: 

802 raise OperationError(tr("読み込めるCELLの紐づけがありません。")) 

803 

804 links = "\n".join( 

805 tr("・{get} → {name}").format(get=column.get('name', 'CELL'), name=current_layers[layer_index].name) 

806 for column, layer_index, _states, _bank in prepared 

807 ) 

808 if confirm: 

809 answer = QMessageBox.question( 

810 self.window, 

811 tr("XDTSタイムシートを反映"), 

812 tr("反映範囲:{value}~{value2}フレーム\n紐づけ:\n{links}\n\n紐づけたレイヤーの対象範囲を置き換えます。").format(value=start_frame + 1, value2=end_frame + 1, links=links), 

813 QMessageBox.StandardButton.Yes 

814 | QMessageBox.StandardButton.No, 

815 QMessageBox.StandardButton.Yes, 

816 ) 

817 if answer != QMessageBox.StandardButton.Yes: 

818 return False 

819 

820 self.window.timeline_ops.set_mode("sheet") 

821 self.window.canvas.push_doc_undo() 

822 for _column, layer_index, states, source_bank in prepared: 

823 self.window.time_remap._apply_time_remap_states_to_layer( 

824 states, 

825 start_frame, 

826 end_frame, 

827 layer_index, 

828 source_bank, 

829 current_frame, 

830 ) 

831 self.window.time_remap.keep_unplaced_in_archive(layer_index, source_bank) 

832 self.window.canvas.current_frame = start_frame 

833 self.window.canvas.active_layer_index = prepared[0][1] 

834 self.window.canvas._onion_cache.clear() 

835 self.window.canvas._color_filter_cache.clear() 

836 self.window.canvas._color_index_cache.clear() 

837 self.window.canvas._silhouette_cache.clear() 

838 self.window._used_color_cache.clear() 

839 self.window.canvas.changed.emit() 

840 self.window.canvas.selectionChanged.emit() 

841 self.window.canvas.update() 

842 self.window.used_color.schedule_refresh() 

843 self.window.statusBar().showMessage( 

844 tr("XDTSの{len}個のCELLを{value}~{value2}フレームへ反映しました。").format(len=len(prepared), value=start_frame + 1, value2=end_frame + 1), 

845 4200, 

846 ) 

847 return True 

848 

849 def psd_dialog(self): 

850 path, _ = QFileDialog.getOpenFileName( 

851 self.window, tr("PSDを読み込む"), "", "Photoshop Document (*.psd)" 

852 ) 

853 if not path: 

854 return 

855 if PSDImage is None or PILImage is None: 

856 QMessageBox.warning( 

857 self.window, 

858 tr("PSD読み込み"), 

859 tr("PSDの読み込みには psd-tools と Pillow が必要です。\n" 

860 "requirements.txtをインストールしてください。"), 

861 ) 

862 return 

863 try: 

864 psd = PSDImage.open(path) 

865 psd_width = int(psd.width) 

866 psd_height = int(psd.height) 

867 if ( 

868 psd_width < 1 

869 or psd_height < 1 

870 or psd_width > MAX_IMAGE_DIMENSION 

871 or psd_height > MAX_IMAGE_DIMENSION 

872 or psd_width * psd_height > MAX_SINGLE_IMAGE_PIXELS 

873 ): 

874 raise OperationError( 

875 tr("PSDの画像サイズが上限を超えています。 ({width} × {height}px)").format(width=psd_width, height=psd_height) 

876 ) 

877 if len(psd) > MAX_PROJECT_LAYERS: 

878 raise OperationError(tr("PSDの最上位レイヤー数が上限を超えています。")) 

879 imported = [] 

880 skipped = 0 

881 imported_cell_count = 0 

882 viewport = (0, 0, psd_width, psd_height) 

883 for top_layer in psd: 

884 frame_layers = ( 

885 list(cast(Any, top_layer)) 

886 if top_layer.is_group() 

887 else [top_layer] 

888 ) 

889 imported_cell_count += len(frame_layers) 

890 if imported_cell_count > MAX_PROJECT_LAYER_CELLS: 

891 raise OperationError(tr("PSDのレイヤー項目数が上限を超えています。")) 

892 key_images = [] 

893 for psd_layer in frame_layers: 

894 try: 

895 rendered = psd_layer.composite( 

896 viewport=viewport, 

897 force=True, 

898 ) 

899 except (ValueError, KeyError, IndexError, TypeError, OSError, RuntimeError, AttributeError) as exc: 

900 log.debug("PSD layer composite failed, skipping: %s", exc) 

901 rendered = None 

902 if rendered is None: 

903 skipped += 1 

904 continue 

905 key_images.append( 

906 PaintCanvas._pil_rgba_to_qimage(rendered) 

907 ) 

908 if key_images: 

909 imported.append(( 

910 str(top_layer.name or "Layer"), 

911 bool(top_layer.is_visible()), 

912 max(0.0, min(1.0, float(top_layer.opacity) / 255.0)), 

913 key_images, 

914 )) 

915 else: 

916 skipped += 1 

917 if not imported: 

918 raise OperationError(tr("読み込める画像レイヤーがありません。")) 

919 except _OPERATION_ERRORS as exc: 

920 log.error("PSD import failed: %s", exc, exc_info=True) 

921 QMessageBox.critical( 

922 self.window, tr("PSD読み込み"), tr("PSDを読み込めませんでした。\n\n{exc}").format(exc=exc) 

923 ) 

924 return 

925 

926 if int(psd.width) > constants.CANVAS_WIDTH or int(psd.height) > constants.CANVAS_HEIGHT: 

927 self.window.canvas.push_doc_undo() 

928 self.window.replace_doc( 

929 max(constants.CANVAS_WIDTH, int(psd.width)), 

930 max(constants.CANVAS_HEIGHT, int(psd.height)), 

931 preserve=True, 

932 ) 

933 self.window.canvas.push_doc_undo() 

934 start_frame = int(self.window.canvas.current_frame) 

935 maximum_keys = max(len(images) for _name, _visible, _opacity, images in imported) 

936 self.window.canvas._ensure_frame_count(start_frame + maximum_keys) 

937 first_new_layer = len(self.window.canvas.frames[0].layers) 

938 for name, visible, opacity, key_images in imported: 

939 layer_index = len(self.window.canvas.frames[0].layers) 

940 for frame in self.window.canvas.frames: 

941 frame.layers.append(Layer( 

942 name, 

943 blank_image(), 

944 visible=visible, 

945 opacity=opacity, 

946 )) 

947 for offset, image in enumerate(key_images): 

948 target = self.window.canvas.frames[start_frame + offset].layers[layer_index] 

949 self.window.canvas._place_imported_image(image, target.image) 

950 target.has_content = True 

951 target.exposure = 1 

952 target.sequence_number = offset + 1 

953 self.window.canvas.current_frame = start_frame 

954 self.window.canvas.active_layer_index = first_new_layer 

955 self.window.canvas.timeline_mode = "sheet" 

956 self.window.timeline.set_timeline_mode("sheet") 

957 self.window.canvas._cell_structure_dirty = True 

958 self.window.canvas.changed.emit() 

959 self.window.canvas.selectionChanged.emit() 

960 self.window.canvas.update() 

961 message = tr("PSDから{len}レイヤーを読み込みました。").format(len=len(imported)) 

962 if skipped: 

963 message += tr("\n調整レイヤーなど{skipped}項目は破棄しました。").format(skipped=skipped) 

964 QMessageBox.information(self.window, tr("PSD読み込み"), message)