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
« 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``.
3Drives the file dialogs and the parsing that brings external PSD layers, XDTS
4timesheets and CLIP STUDIO animations into the current project.
6A collaborator rather than a mixin -- see ``main_window_export.py`` for why.
7"""
8from typing import TYPE_CHECKING, Any, cast
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
43if TYPE_CHECKING:
44 from .main_window import MainWindow
46log = get_logger(__name__)
49class _ClipReadWorker(QThread):
50 progress = Signal(int, int, str)
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
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))
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)
89 def reject(self):
90 # Escape must not close the modal guard while the worker is running.
91 pass
93 def closeEvent(self, event):
94 event.ignore()
97class _ClipProgressDisplay(QObject):
98 def __init__(self, dialog):
99 super().__init__(dialog)
100 self.dialog = dialog
101 self.bar = dialog.bar
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)
114class ImportController:
115 """Owned by ``MainWindow`` as ``window.import_``.
117 A collaborator rather than a mixin -- see main_window_export.py for why.
118 """
120 def __init__(self, window: "MainWindow"):
121 self.window = window
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
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 )
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 )
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
177 def _read_clip_with_progress(self, source):
178 dialog = _ClipImportDialog(self.window)
179 self._clip_import_active = True
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
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
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
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
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
321 def read_cut_folder(self, root):
322 """カットフォルダーを解析し、``(document, notes)`` を返す。
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("タイムシートにセルの列がありません。"))
350 size = None
351 resized = 0
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
380 def column_images(drawings):
381 return {
382 str(number): load(path)
383 for number, path in sorted(drawings.items())
384 }
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 ))
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
446 def _replace_document(self, parsed, *, title, source_metadata):
447 """現在のドキュメントを読み込んだアニメーションで置き換える。
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
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", [])))
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"
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 = ""
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)
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))
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 }
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の紐づけがありません。"))
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
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
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
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)