Coverage for paintmaskanimator/main_window.py: 51%
677 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"""The ``MainWindow`` — application shell, menu/dock wiring, and top-level actions.
3Architecture: each cohesive feature lives in ``main_window_<topic>.py`` as a
4*controller* the window owns and reaches by name -- ``window.export``,
5``window.project``, ``window.timeline_ops`` and the rest, constructed in
6``__init__`` below. A controller takes the window in its constructor, so its
7dependencies are visible as ``self.window.…`` rather than appearing out of
8nowhere, and it can be built against a stub window in a test.
10These were mixins until they were migrated one by one. Composing them all onto
11one class put fifteen unrelated feature namespaces into a single ``self``, where
12two siblings could silently claim the same method name and no checker could see
13which one provided any given attribute.
15Three mixins remain, because their methods genuinely belong to the window
16object: ``UIBuildMixin`` constructs the window's own widgets, and ``InputMixin``
17and ``DockingMixin`` implement Qt overrides (``eventFilter``) that Qt calls on
18the window itself.
20What remains here is the irreducible shell: application state held on the
21window, the ``PaintCanvas`` <-> timeline glue, playback, and document lifecycle
22(``new_doc``/``replace_doc``). Type-only member declarations for the remaining
23mixins live in ``_main_window_members.py``; the shared error set is
24``errors.OPERATION_ERRORS`` (aliased ``_OPERATION_ERRORS``).
25"""
26import math
27import numpy as np
28import subprocess
29import sys
30import tempfile
31import time
32from pathlib import Path
33from typing import Any
34from PySide6.QtCore import QPointF, QRectF, QTimer, Qt
35from PySide6.QtGui import QCursor, QImage, QImageReader, QPainter
36from PySide6.QtWidgets import (
37 QApplication,
38 QDialog,
39 QFileDialog,
40 QMainWindow,
41 QMessageBox,
42 QProgressDialog,
43)
44from .i18n import tr
45from .optional_deps import PILImage
46from .constants import APP_DISPLAY_NAME, OUTSIDE_MARGIN
47from . import color_ops, constants, imaging, layout_paper, preferences, pressure_settings, project_io, session_state, theme, updater
48from .actionpanel import ActionPanel
49from .canvas import PaintCanvas
50from .errors import OPERATION_ERRORS
51from .color_panel import UsedColorPanel
52from .color_chart import ColorChartPanel, empty_color_chart
53from .history_panel import HistoryPanel
54from .memo_window import MemoWindow
55from .subview import SubViewWidget
56from .color_reduction import ColorReductionDialog
57from .models import Frame, Layer, make_frame
58from .pressure import PressureDialog
59from .timeline import TimelineWidget
60from .toolpanel import ToolPanel, ToolSelectorPanel
61from .utils import blank_image, disable_windows_ink_feedback, install_app_event_filter, remove_app_event_filter, workspace_size
62from .new_document_dialog import NewDocumentDialog
63from .widgets import (CanvasSizeDialog, ShortcutDialog)
64from .progress import close_counter, create_counter, update_counter
65from .logging_setup import get_logger
66from .main_window_autosave import AutosaveController
67from .main_window_shortcuts import ShortcutStore
68from .main_window_color_interaction import ColorInteractionController
69from .main_window_color_chart import ColorChartController
70from .main_window_docking import DockingMixin
71from .main_window_export import ExportController
72from .main_window_import import ImportController
73from .main_window_input import InputMixin
74from .main_window_layer_ops import LayerOpsController
75from .main_window_line_ops import LineOpsController
76from .main_window_view_preview import ViewPreviewController
77from .main_window_tool_hold import ToolHoldController
78from .main_window_update_notice import UpdateNoticeController
79from .main_window_onion import OnionSkinController
80from .main_window_project_io import ProjectIOController
81from .main_window_time_remap import TimeRemapController
82from .main_window_timeline_ops import TimelineOpsController
83from .main_window_tween import TweenController
84from .main_window_ui_build import UIBuildMixin
85from .main_window_used_color import UsedColorController
86from .main_window_scope_ops import ScopeOpsController
87from .main_window_reference_check import ReferenceCheckController
88from .main_window_auto_paint import AutoPaintController
89from .main_window_initial_state import InitialStateController
90from .main_window_workspace import WorkspaceController
92log = get_logger(__name__)
94# Realistic failure set for the top-level user-action handlers (shared with the
95# extracted mixins); see errors.py.
96_OPERATION_ERRORS = OPERATION_ERRORS
99class MainWindow(
100 UIBuildMixin, InputMixin, DockingMixin, QMainWindow
101):
102 def __init__(self):
103 super().__init__();self.setWindowTitle(APP_DISPLAY_NAME);self._fit_window_to_screen(1500,960);self.setAcceptDrops(True)
104 self.canvas=PaintCanvas();self.tool_selector=ToolSelectorPanel();self.tools=ToolPanel();self.timeline=TimelineWidget();self.palette=UsedColorPanel();self.history_panel=HistoryPanel();self.subview=SubViewWidget(self);self.color_chart=ColorChartPanel(self);self.color_chart_data=empty_color_chart();self.timer=QTimer(self);self.timer.timeout.connect(self.advance)
105 # Feature controllers the window owns; see this module's docstring.
106 self.export=ExportController(self)
107 self.workspace=WorkspaceController(self)
108 self.autosave=AutosaveController(self)
109 self.colors=ColorInteractionController(self)
110 self.used_color=UsedColorController(self)
111 self.line_ops=LineOpsController(self)
112 self.view_preview=ViewPreviewController(self)
113 self.tool_hold=ToolHoldController(self)
114 self.update_notice=UpdateNoticeController(self)
115 self.timeline_ops=TimelineOpsController(self)
116 self.layers=LayerOpsController(self)
117 self.color_chart_ops=ColorChartController(self)
118 self.onion=OnionSkinController(self)
119 self.tween=TweenController(self)
120 self.time_remap=TimeRemapController(self)
121 self.scope=ScopeOpsController(self)
122 self.reference_check=ReferenceCheckController(self)
123 self.auto_paint=AutoPaintController(self)
124 self.initial_state=InitialStateController(self)
125 self.project=ProjectIOController(self)
126 self.importer=ImportController(self)
127 # palette_state のUndo/Redoでパネル状態を復元できるよう相互参照を張る。
128 self.canvas._palette=self.palette
129 self.history_panel.set_canvas(self.canvas)
130 self.timer.setTimerType(Qt.TimerType.PreciseTimer)
131 self._playback_started_at=None
132 self._playback_emitted_steps=0
133 self._used_color_cache = {}
134 self._used_color_layer_cache = {}
135 self._used_color_request = 0
136 self._used_color_timer = QTimer(self)
137 self._used_color_timer.setSingleShot(True)
138 self._used_color_timer.setInterval(180)
139 self._used_color_timer.timeout.connect(self.used_color.refresh)
140 self._pending_visible_colors = None
141 self._pending_palette_categories = None
142 self._suppress_used_color_refresh_once = False
143 self._tween_command_popup = None
144 self._onion_settings_browser = None
145 self._onion_settings_window = None
146 self._held_canvas_shortcut_tokens = set()
147 self._space_tap_started = None
148 self._ui_hold_drag_mode = None
149 self._ui_hold_scroll_area = None
150 self._ui_hold_grab_widget = None
151 self._ui_hold_start_global = QPointF()
152 self._ui_hold_last_global = QPointF()
153 self._ui_hold_start_scroll = (0, 0)
154 self._visible_color_timer = QTimer(self)
155 self._visible_color_timer.setSingleShot(True)
156 self._visible_color_timer.setInterval(20)
157 self._visible_color_timer.timeout.connect(self.colors._apply_pending_visible_colors)
158 self._pending_tool_selector_snap = None
159 self._tool_selector_resize_drag_active = False
160 self._tool_selector_snap_timer = QTimer(self)
161 self._tool_selector_snap_timer.setSingleShot(True)
162 self._tool_selector_snap_timer.setInterval(180)
163 self._tool_selector_snap_timer.timeout.connect(
164 self._apply_tool_selector_snap
165 )
166 self._split_drop_candidate = None
167 self._split_drop_dragged_dock = None
168 self._split_drop_source_area = None
169 self._split_drop_press_pos = None
170 self._dock_menu_builders = {}
171 self.current_project_path = None
172 # カット袋・シートのメモ欄(Issue #25)。窓は開いたときに作る。
173 self.project_memo = ""
174 self._memo_window = None
175 # 保存・自動保存で、変更のないセルのPNGを使い回す。
176 self.png_cache = project_io.PngCache()
177 self.build_actions();self.action_panel=ActionPanel(self);self.build_action_panel();self.build_menu();self.build_ui();self.connect_signals();self.refresh_ui();self.action_panel.reload_python_actions()
178 # 保存したショートカットを、既定を覚えてから反映する。
179 self.shortcut_store = ShortcutStore(self)
180 self.shortcut_store.register_actions()
181 self.shortcut_store.load()
182 self._refresh_theme_dependent_ui()
183 self.colors._sync_tool_selector_swatch()
184 install_app_event_filter(self)
185 self.project.update_title()
186 QTimer.singleShot(0,self.fit_canvas)
187 QTimer.singleShot(
188 0,
189 lambda: disable_windows_ink_feedback(
190 self,
191 self.canvas,
192 ),
193 )
194 self.apply_pressure_settings()
195 session_state.restore(self)
196 self.project.mark_saved()
197 self._title_timer = QTimer(self)
198 self._title_timer.setInterval(500)
199 self._title_timer.timeout.connect(self.project.update_title)
200 self._title_timer.start()
201 self.autosave.start()
202 QTimer.singleShot(0, self.autosave.maybe_restore)
203 def status(self, message, level="info", timeout=4000):
204 """Show a severity-coloured message in the bottom status bar.
206 ``level`` is one of ``info`` / ``success`` / ``warning`` / ``error``.
207 Falls back to the plain status bar if the styled one is unavailable.
208 """
209 bar: Any = self.statusBar()
210 show = getattr(bar, "show_message", None)
211 if callable(show):
212 show(message, level, timeout)
213 else:
214 bar.showMessage(message, timeout)
216 def set_theme(self, name):
217 """Switch the light/dark theme, persist it, and refresh the UI."""
218 app = QApplication.instance()
219 if app is not None: 219 ↛ 221line 219 didn't jump to line 221 because the condition on line 219 was always true
220 theme.apply_theme(app, name, persist=True)
221 self._refresh_theme_dependent_ui()
223 def _on_canvas_cell_changed(self, frame_index, layer_index):
224 self.canvas.sync_numbered_image_from_cell(
225 frame_index,
226 layer_index,
227 )
228 # Existing brush cells do not change timeline structure. Rebuilding the
229 # entire table here made brush release scale with the number of imported images.
230 structure_dirty = bool(
231 getattr(self.canvas, "_cell_structure_dirty", True)
232 )
233 self.canvas._cell_structure_dirty = True
234 if structure_dirty:
235 self.timeline.update_cell(
236 self.canvas.frames, frame_index, layer_index
237 )
239 # ブラシは常に100%不透明で、色は colorUsed で即時追加される。
240 # 通常ストロークのたびに全フレーム走査するのを避ける。
241 normal_opaque_brush = (
242 self.canvas.effective_tool() == "brush"
243 and not self.canvas.is_pseudo_transparent_color(
244 self.canvas.paint_source_color()
245 )
246 )
247 if not normal_opaque_brush:
248 self.used_color.schedule_refresh()
250 def refresh_ui(self):
251 self.canvas.coalesce_numbered_images()
252 self.canvas.link_layer_tracks()
253 self.timeline.sequence_archive = self.canvas._sequence_archive
254 self.timeline.playback_range_start = int(self.canvas.playback_range_start)
255 self.timeline.playback_range_end = self.canvas.playback_range_end
256 self.timeline.refresh(
257 self.canvas.frames,
258 self.canvas.current_frame,
259 self.canvas.active_layer_index,
260 getattr(self.canvas, "tween_pending", None),
261 )
262 if self._suppress_used_color_refresh_once:
263 self._suppress_used_color_refresh_once = False
264 else:
265 self.used_color.schedule_refresh()
268 def refresh_selection(self):
269 if getattr(self, "_closing", False) or not self.canvas.frames: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 return
271 self.canvas.current_frame = max(0, min(self.canvas.current_frame, len(self.canvas.frames) - 1))
272 layers = self.canvas.layers
273 if not layers: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 return
275 self.canvas.active_layer_index = max(0, min(self.canvas.active_layer_index, len(layers) - 1))
276 self.timeline.select_current(self.canvas.current_exposure(), self.canvas.active_layer_index)
277 self.timeline.layer_list.blockSignals(True)
278 self.timeline.layer_list.setCurrentRow(len(layers) - 1 - self.canvas.active_layer_index)
279 self.timeline.layer_list.blockSignals(False)
280 self.timeline.duration.blockSignals(True)
281 layer = self.canvas.frames[self.canvas.current_frame].layers[self.canvas.active_layer_index]
282 if not layer.has_content:
283 source = self.canvas.resolve_key_frame(self.canvas.current_frame, self.canvas.active_layer_index)
284 layer = self.canvas.frames[source].layers[self.canvas.active_layer_index] if source is not None else layer
285 self.timeline.duration.setValue(max(1, layer.exposure))
286 self.timeline.duration.blockSignals(False)
287 self.timeline._sync_layer_opacity_slider(layer.opacity)
290 def prepare_color_reduction(self, paths):
291 paths = sorted(
292 [str(path) for path in paths],
293 key=self.canvas._natural_path_key,
294 )
295 if not paths:
296 return None, ""
298 first_image, error = self.canvas._read_image_file(
299 paths[0]
300 )
301 if first_image is None:
302 return None, (
303 f"{Path(paths[0]).name}\n{error}"
304 )
306 # パレットは代表フレームからまとめて作る。1枚目だけで決めると、
307 # 中間で色が増えるカットやフラッシュで色が潰れる(Issue #10)。
308 reference_images = []
309 for index in color_ops.reference_frame_indices(len(paths)):
310 image, _error = self.canvas._read_image_file(paths[index])
311 if image is not None and not image.isNull():
312 reference_images.append(image)
314 try:
315 color_count = (
316 self.canvas.opaque_rgb_color_count(first_image)
317 )
318 alpha_statistics = (
319 self.canvas.image_alpha_statistics(first_image)
320 )
321 background_rgb = (
322 self.canvas.detect_opaque_border_background(
323 first_image
324 )
325 )
326 opaque_background = background_rgb is not None
327 except _OPERATION_ERRORS as exc:
328 log.warning("first-image color/alpha inspection failed: %s", exc, exc_info=True)
329 return None, (
330 tr("1枚目の色と透明度を確認できませんでした。\n{exc}").format(exc=exc)
331 )
333 semi_transparent_count = int(
334 alpha_statistics["semi_transparent"]
335 )
337 # 多色、半透明AA、または白背景画像を調整対象にする。
338 if (
339 color_count < 100
340 and semi_transparent_count <= 0
341 and not opaque_background
342 ):
343 return None, ""
345 try:
346 dialog = ColorReductionDialog(
347 first_image,
348 color_count,
349 semi_transparent_count,
350 opaque_background,
351 background_rgb,
352 self,
353 reference_images=reference_images,
354 )
355 if (
356 dialog.exec()
357 != QDialog.DialogCode.Accepted
358 ):
359 return None, "__cancelled__"
360 if not dialog.reduction_enabled:
361 return None, ""
362 palette = dialog.selected_palette()
363 except _OPERATION_ERRORS as exc:
364 log.warning("binarization preparation failed: %s", exc, exc_info=True)
365 return None, (
366 tr("2値化の準備中にエラーが発生しました。\n{exc}").format(exc=exc)
367 )
369 return {
370 "palette": palette,
371 "extraction_mode": dialog.selected_extraction_mode(),
372 "target_colors": int(
373 dialog.color_count.value()
374 ),
375 "source_color_count": int(color_count),
376 "alpha_threshold": int(
377 dialog.alpha_threshold_255()
378 ),
379 "semi_transparent_source_pixels": (
380 semi_transparent_count
381 ),
382 "opaque_background": bool(
383 dialog.opaque_background
384 ),
385 "background_rgb": (
386 tuple(dialog.background_rgb)
387 if dialog.background_rgb is not None
388 else None
389 ),
390 "tone_curve_points": [
391 (float(x), float(y))
392 for x, y in dialog.tone_curve_points()
393 ],
394 }, ""
396 def prepare_image_import(self, paths):
397 # Keep this step light: inspect dimensions only. Color analysis is deferred
398 # until after the image is visible and is cached per cell.
399 max_width = constants.CANVAS_WIDTH
400 max_height = constants.CANVAS_HEIGHT
401 for path in paths:
402 reader = QImageReader(str(path))
403 if hasattr(reader, "setDecideFormatFromContent"):
404 reader.setDecideFormatFromContent(True)
405 size = reader.size()
406 if size.isValid():
407 width, height = size.width(), size.height()
408 elif PILImage is not None:
409 try:
410 with PILImage.open(path) as pil:
411 width, height = pil.size
412 except (OSError, ValueError, TypeError) as exc:
413 log.info("PIL size read of %s failed: %s", path, exc)
414 return False, tr("{Path}\n画像サイズを取得できませんでした。\n{exc}").format(Path=Path(path).name, exc=exc)
415 else:
416 image, error = self.canvas._read_image_file(path)
417 if image is None:
418 return False, f"{Path(path).name}\n{error}"
419 width, height = image.width(), image.height()
420 max_width = max(max_width, width)
421 max_height = max(max_height, height)
422 if max_width > constants.CANVAS_WIDTH or max_height > constants.CANVAS_HEIGHT:
423 self.canvas.push_doc_undo()
424 self.replace_doc(max_width, max_height, preserve=True)
425 self.statusBar().showMessage(
426 tr("画像に合わせてキャンバスを {width} × {height}px に拡張しました。").format(width=max_width, height=max_height), 3500)
427 return True, ""
429 def import_dropped_image(self, path):
430 color_reduction, error = (
431 self.prepare_color_reduction([path])
432 )
433 if error:
434 if error != "__cancelled__":
435 QMessageBox.warning(
436 self,
437 tr("画像読み込み"),
438 tr("{Path} を読み込めませんでした。\n\n{error}").format(Path=Path(path).name, error=error),
439 )
440 return
442 prepared, error = self.prepare_image_import([path])
443 if not prepared:
444 if error != "__cancelled__":
445 QMessageBox.warning(self, tr("画像読み込み"), tr("{Path} を読み込めませんでした。\n\n{error}").format(Path=Path(path).name, error=error))
446 return
447 ok, error = self.canvas.import_image(
448 path,
449 color_reduction=color_reduction,
450 )
451 if not ok:
452 QMessageBox.warning(self,tr("画像読み込み"),tr("{Path} を読み込めませんでした。\n\n{error}").format(Path=Path(path).name, error=error))
453 else:
454 self.timeline_ops.set_mode("sequence")
455 self._used_color_cache.clear()
456 self.used_color.schedule_refresh()
458 def import_dropped_images(self, paths, layer_name=None):
459 color_reduction, error = (
460 self.prepare_color_reduction(paths)
461 )
462 if error:
463 if error != "__cancelled__":
464 QMessageBox.warning(
465 self,
466 tr("連番画像読み込み"),
467 tr("画像を読み込めませんでした。\n\n{error}").format(error=error),
468 )
469 return
471 prepared, error = self.prepare_image_import(paths)
472 if not prepared:
473 if error != "__cancelled__":
474 QMessageBox.warning(self, tr("連番画像読み込み"), tr("画像を読み込めませんでした。\n\n{error}").format(error=error))
475 return
476 # 画像配置だけでなく、その直後の使用色認識まで同じカウンターで表示する。
477 estimated_frames = max(1, len(self.canvas.frames) + len(paths))
478 combined_total = max(1, len(paths) + estimated_frames)
479 progress = create_counter(
480 self,
481 tr("連番画像読み込み"),
482 combined_total,
483 tr("画像を読み込んでいます"),
484 )
485 ok = False
486 error = ""
487 try:
488 ok, error = self.canvas.import_image_sequence(
489 paths,
490 lambda value, total, label: update_counter(
491 progress,
492 value,
493 combined_total,
494 tr("{label}(画像 {value}/{total})").format(label=label, value=value, total=total),
495 ),
496 color_reduction=color_reduction,
497 layer_name=layer_name,
498 )
499 if ok:
500 self.timeline_ops.set_mode("sequence")
501 self._used_color_timer.stop()
502 self._used_color_request += 1
503 self._used_color_cache.clear()
505 actual_frames = len(self.canvas.frames)
506 combined_total = max(1, len(paths) + actual_frames)
507 progress.setMaximum(combined_total)
508 update_counter(
509 progress,
510 len(paths),
511 combined_total,
512 tr("画像配置完了。使用色を認識しています"),
513 )
514 self.used_color.refresh(
515 progress=progress,
516 progress_offset=len(paths),
517 progress_total=combined_total,
518 progress_label=tr("使用色を認識しています"),
519 )
520 finally:
521 close_counter(progress)
523 if not ok:
524 QMessageBox.warning(
525 self,
526 tr("連番画像読み込み"),
527 tr("画像を読み込めませんでした。\n\n{error}").format(error=error),
528 )
529 elif len(paths) > 1:
530 reduction_note = ""
531 if color_reduction:
532 reduction_method = (
533 tr("元画像へトーンカーブ適用後に2値化")
534 if color_reduction.get("opaque_background")
535 else tr("半透明を二値化")
536 )
537 reduction_note = (
538 tr("、{method}して1枚目の共通パレット{int}色を適用").format(method=reduction_method, int=int(color_reduction['target_colors']))
539 )
540 self.statusBar().showMessage(
541 tr("{len}枚の画像をタイムラインへ連番配置{note}し、使用色認識まで完了しました。").format(len=len(paths), note=reduction_note),
542 3600,
543 )
546 def sync_canvas_view_controls(self, zoom_value, rotation_value):
547 zoom_percent = max(self.zoom.minimum(), min(self.zoom.maximum(), int(round(float(zoom_value) * 100))))
548 rotation_degrees = max(-180, min(180, int(round(float(rotation_value)))))
549 self.zoom.blockSignals(True); self.zoom.setValue(zoom_percent); self.zoom.blockSignals(False)
550 self.rot.blockSignals(True); self.rot.setValue(rotation_degrees); self.rot.blockSignals(False)
551 self.zoom_label.setText(f"{zoom_percent}%")
552 self.rot_label.setText(f"{rotation_degrees}°")
554 def paste_from_clipboard(self):
555 """Ctrl+V。タイムラインの上ならタイムシートをそのまま反映、それ以外は画像を貼る。"""
556 if self._pointer_or_focus_on_timeline() and self.time_remap.paste_on_timeline():
557 return
558 self.canvas.paste_clipboard()
560 def _pointer_or_focus_on_timeline(self):
561 timeline = self.timeline
562 if not timeline.isVisible(): 562 ↛ 563line 562 didn't jump to line 563 because the condition on line 562 was never true
563 return False
564 focus = QApplication.focusWidget()
565 if focus is not None and (focus is timeline or timeline.isAncestorOf(focus)): 565 ↛ 567line 565 didn't jump to line 567 because the condition on line 565 was always true
566 return True
567 return timeline.rect().contains(timeline.mapFromGlobal(QCursor.pos()))
569 def step_canvas_zoom(self, factor):
570 """Ctrl++/Ctrl+− で、マウスカーソルの位置を中心に拡大縮小する。
572 ホイールと同じく、見ている所をそのまま拡大できるように。カーソルが
573 キャンバスの外(パネルの上など)にあっても、その位置を中心にする。
574 """
575 zoom = self.canvas.zoom * float(factor)
576 cursor = self.canvas.mapFromGlobal(QCursor.pos())
577 self.canvas.set_zoom_around_widget_point(zoom, QPointF(cursor))
578 self.sync_canvas_view_controls(self.canvas.zoom, self.canvas.rotation)
579 self.canvas.update()
581 def set_zoom(self,v):
582 self.canvas.set_zoom_around_canvas_center(v / 100)
583 self.sync_canvas_view_controls(self.canvas.zoom, self.canvas.rotation)
584 self.canvas.update()
585 def show_canvas_at_100_percent(self):
586 self.canvas.zoom = 1.0
587 self.canvas.center_canvas()
588 self.sync_canvas_view_controls(self.canvas.zoom, self.canvas.rotation)
589 self.canvas.update()
590 def set_rot(self,v):
591 self.canvas.rotation=float(v)
592 self.sync_canvas_view_controls(self.canvas.zoom, self.canvas.rotation)
593 self.canvas.update()
594 def fit_canvas(self):
595 w,h=workspace_size();availw=max(100,self.canvas.width()-40);availh=max(100,self.canvas.height()-40);z=min(availw/w,availh/h);self.canvas.zoom=z;self.canvas.pan=QPointF((self.canvas.width()-w*z)/2,(self.canvas.height()-h*z)/2);self.zoom.blockSignals(True);self.zoom.setValue(int(z*100));self.zoom.blockSignals(False);self.zoom_label.setText(f"{z*100:.0f}%");self.canvas.update()
597 def new_doc(self):
598 d=NewDocumentDialog(*preferences.new_canvas_size(),self)
599 if d.exec(): 599 ↛ exitline 599 didn't return from function 'new_doc' because the condition on line 599 was always true
600 self.replace_doc(*d.values())
601 layout_paper.set_last_used(d.paper_id())
602 if d.paper_id() is not None: 602 ↛ 603line 602 didn't jump to line 603 because the condition on line 602 was never true
603 self.insert_layout_paper(layout_paper.paper(d.paper_id()))
604 self.current_project_path = None
605 self.project.update_title()
606 self.project.mark_saved()
608 def insert_layout_paper(self, entry):
609 """Put a layout paper under every layer of a fresh document, as a draft."""
610 image = layout_paper.load_image(entry) if entry else None
611 if image is None: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true
612 self.status(tr("レイアウト用紙の画像を読み込めませんでした。"), "warning")
613 return
614 for frame in self.canvas.frames:
615 layer = Layer(layout_paper.layer_name(), blank_image(), is_draft=True)
616 self.canvas._place_imported_image(image, layer.image)
617 layer.has_content = True
618 frame.layers.insert(0, layer)
619 self.canvas.active_layer_index = 1
620 self.refresh_ui()
622 def check_for_updates_interactive(self):
623 # Reads the public GitHub Releases API; no token/credential is required.
624 QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
625 try:
626 result = updater.check_for_update()
627 finally:
628 QApplication.restoreOverrideCursor()
629 # メニューバーのお知らせも、確かめた結果に合わせる。
630 self.update_notice.apply_result(result)
632 status = result.get("status")
633 if status == "error": 633 ↛ 634line 633 didn't jump to line 634 because the condition on line 633 was never true
634 QMessageBox.warning(
635 self, tr("更新確認エラー"), result.get("message", tr("不明なエラー"))
636 )
637 return
638 if status == "up_to_date": 638 ↛ 646line 638 didn't jump to line 646 because the condition on line 638 was always true
639 QMessageBox.information(
640 self,
641 tr("更新の確認"),
642 tr("最新版を使用しています。(現在: v{VERSION})").format(VERSION=constants.APP_VERSION),
643 )
644 return
646 latest = result.get("latest", "")
647 asset = result.get("asset")
648 if not asset:
649 QMessageBox.information(
650 self,
651 tr("更新あり"),
652 tr("新しいバージョン {latest} が利用可能ですが、この環境向けの\nインストーラが見つかりませんでした。リリースページを確認してください。\n\n{url}").format(latest=latest, url=constants.RELEASES_URL),
653 )
654 return
656 answer = QMessageBox.question(
657 self,
658 tr("更新があります"),
659 tr("新しいバージョン {latest} が利用可能です。\n(現在: v{VERSION})\n\nダウンロードしてインストールしますか?").format(latest=latest, VERSION=constants.APP_VERSION),
660 )
661 if answer != QMessageBox.StandardButton.Yes:
662 return
663 self._download_and_run_installer(asset, latest)
665 def _download_and_run_installer(self, asset, latest):
666 dest = Path(tempfile.gettempdir()) / str(
667 asset.get("name", f"PaintMaskAnimator-Setup-{latest}.exe")
668 )
669 dialog = QProgressDialog(
670 tr("更新をダウンロードしています…"), tr("キャンセル"), 0, 100, self
671 )
672 dialog.setWindowTitle(tr("更新のダウンロード"))
673 dialog.setAutoClose(False)
674 dialog.setMinimumDuration(0)
675 cancelled = {"flag": False}
676 dialog.canceled.connect(lambda: cancelled.__setitem__("flag", True))
678 def on_progress(downloaded, total):
679 if total > 0:
680 dialog.setValue(int(downloaded * 100 / total))
681 QApplication.processEvents()
682 if cancelled["flag"]:
683 raise RuntimeError("cancelled")
685 try:
686 updater.download_asset(asset, dest, progress=on_progress)
687 except RuntimeError:
688 dialog.close()
689 return
690 except (OSError, ValueError) as error:
691 log.warning("update download failed: %s", error, exc_info=True)
692 dialog.close()
693 QMessageBox.warning(
694 self, tr("ダウンロード失敗"), tr("更新を取得できませんでした。\n\n{error}").format(error=error)
695 )
696 return
697 dialog.close()
699 answer = QMessageBox.question(
700 self,
701 tr("インストール"),
702 tr("ダウンロードが完了しました。インストーラを起動して\n"
703 "アプリを終了します。よろしいですか?"),
704 )
705 if answer != QMessageBox.StandardButton.Yes:
706 return
707 try:
708 import os
709 if sys.platform == "win32":
710 os.startfile(str(dest)) # noqa: SLF001 - Windows installer launch
711 elif sys.platform == "darwin":
712 subprocess.Popen(["open", str(dest)])
713 else:
714 subprocess.Popen(["xdg-open", str(dest)])
715 except (OSError, ValueError) as error:
716 log.warning("installer launch failed: %s", error, exc_info=True)
717 QMessageBox.warning(
718 self, tr("起動失敗"), tr("インストーラを起動できませんでした。\n\n{error}").format(error=error)
719 )
720 return
721 self.close()
724 def build_project_metadata(self):
725 """Assemble the project metadata dict from current widget state."""
726 return {
727 "format": "PaintMaskAnimatorProject",
728 "format_version": 1,
729 "mask_format": project_io.CURRENT_MASK_FORMAT,
730 "application_version": 100,
731 "canvas": {
732 "width": int(constants.CANVAS_WIDTH),
733 "height": int(constants.CANVAS_HEIGHT),
734 },
735 "fps": int(self.timeline.fps.value()),
736 "playback_range": [
737 int(self.canvas.playback_range_start),
738 int(self.canvas.playback_range_end)
739 if self.canvas.playback_range_end is not None else None,
740 ],
741 "timeline_mode": str(self.canvas.timeline_mode),
742 # カット袋・シートのメモ欄(Issue #25)。
743 "memo": str(self.project_memo),
744 "clip_studio_source": getattr(
745 self.canvas, "clip_studio_source_metadata", None
746 ),
747 "current_frame": int(self.canvas.current_frame),
748 "active_layer_index": int(self.canvas.active_layer_index),
749 "colors": {
750 "main": self.colors.image_color_hex(self.canvas.main_color),
751 "sub": self.colors.image_color_hex(self.canvas.sub_color),
752 "mode": self.canvas.color_mode,
753 "background": self.colors.image_color_hex(
754 self.canvas.transparent_display_color
755 ),
756 },
757 "display": {
758 "silhouette_non_background": bool(
759 self.canvas.silhouette_non_background
760 ),
761 "onion_skin": bool(self.canvas.onion_skin),
762 "onion_previous_count": int(self.canvas.onion_previous_count),
763 "onion_next_count": int(self.canvas.onion_next_count),
764 "onion_previous_opacity": float(self.canvas.onion_previous_opacity),
765 "onion_next_opacity": float(self.canvas.onion_next_opacity),
766 "onion_previous_levels": [
767 int(value)
768 for value in self.canvas.onion_previous_levels
769 ],
770 "onion_next_levels": [
771 int(value)
772 for value in self.canvas.onion_next_levels
773 ],
774 "onion_center_percent": float(
775 self.canvas.onion_center_percent
776 ),
777 "onion_previous_color": self.colors.image_color_hex(
778 self.canvas.onion_previous_color
779 ),
780 "onion_next_color": self.colors.image_color_hex(
781 self.canvas.onion_next_color
782 ),
783 "onion_previous_color_enabled": bool(
784 self.canvas.onion_previous_color_enabled
785 ),
786 "onion_next_color_enabled": bool(
787 self.canvas.onion_next_color_enabled
788 ),
789 "onion_selected_colors_only": bool(
790 self.canvas.onion_selected_colors_only
791 ),
792 "onion_previous_shift_x": float(
793 self.canvas.onion_previous_shift_x
794 ),
795 "onion_previous_shift_y": float(
796 self.canvas.onion_previous_shift_y
797 ),
798 "onion_previous_rotation": float(
799 self.canvas.onion_previous_rotation
800 ),
801 "onion_previous_scale": float(
802 self.canvas.onion_previous_scale
803 ),
804 "onion_next_shift_x": float(
805 self.canvas.onion_next_shift_x
806 ),
807 "onion_next_shift_y": float(
808 self.canvas.onion_next_shift_y
809 ),
810 "onion_next_rotation": float(
811 self.canvas.onion_next_rotation
812 ),
813 "onion_next_scale": float(
814 self.canvas.onion_next_scale
815 ),
816 "onion_tu_tb_scale": float(
817 self.canvas.onion_tu_tb_scale
818 ),
819 },
820 "pressure": {
821 "enabled": bool(self.canvas.pressure_enabled),
822 "minimum": float(self.canvas.pressure_min),
823 "maximum": float(self.canvas.pressure_max),
824 "curve": float(self.canvas.pressure_curve),
825 "points": getattr(self.canvas, "pressure_curve_points", [[0.0,0.0],[1.0,1.0]]),
826 },
827 "used_color_categories": self.palette.serialize_categories(),
828 "color_chart": self.color_chart_ops._normalize(
829 self.color_chart_data
830 ),
831 "frames": [],
832 }
835 def dragEnterEvent(self, event):
836 urls = (
837 event.mimeData().urls()
838 if event.mimeData().hasUrls() else []
839 )
840 if any(
841 Path(url.toLocalFile()).suffix.lower()
842 in (".pman", ".clip", ".xdts", ".xtds")
843 for url in urls
844 ):
845 event.acceptProposedAction()
846 return
847 super().dragEnterEvent(event)
849 def dropEvent(self, event):
850 project_paths = [
851 url.toLocalFile()
852 for url in event.mimeData().urls()
853 if Path(url.toLocalFile()).suffix.lower() == ".pman"
854 ] if event.mimeData().hasUrls() else []
855 remap_paths = [
856 url.toLocalFile()
857 for url in event.mimeData().urls()
858 if Path(url.toLocalFile()).suffix.lower()
859 in (".xdts", ".xtds")
860 ] if event.mimeData().hasUrls() else []
861 clip_paths = [
862 url.toLocalFile()
863 for url in event.mimeData().urls()
864 if Path(url.toLocalFile()).suffix.lower() == ".clip"
865 ] if event.mimeData().hasUrls() else []
866 if project_paths:
867 self.project.open_dropped(project_paths[0])
868 event.acceptProposedAction()
869 return
870 if remap_paths:
871 self.time_remap.open_dropped(remap_paths[0])
872 event.acceptProposedAction()
873 return
874 if clip_paths:
875 self.importer.clip_animation(clip_paths[0])
876 event.acceptProposedAction()
877 return
878 super().dropEvent(event)
881 def import_images_dialog(self):
882 paths, _ = QFileDialog.getOpenFileNames(
883 self,
884 tr("画像を読み込む"),
885 "",
886 tr("画像 (*.png *.jpg *.jpeg *.tga);;"
887 "PNG (*.png);;JPEG (*.jpg *.jpeg);;TGA (*.tga)"),
888 )
889 if not paths:
890 return
891 if len(paths) == 1:
892 self.import_dropped_image(paths[0])
893 else:
894 self.import_dropped_images(paths)
896 def import_image_folder_dialog(self):
897 folder = QFileDialog.getExistingDirectory(
898 self,
899 tr("画像フォルダーを読み込む"),
900 "",
901 )
902 if not folder:
903 return
905 folder_path = Path(folder)
906 supported_suffixes = {".png", ".jpg", ".jpeg", ".tga"}
907 paths = sorted(
908 (
909 path
910 for path in folder_path.iterdir()
911 if path.is_file()
912 and path.suffix.lower() in supported_suffixes
913 ),
914 key=self.canvas._natural_path_key,
915 )
916 if not paths:
917 QMessageBox.information(
918 self,
919 tr("画像フォルダーを読み込む"),
920 tr("選択したフォルダーに対応画像がありません。\n\n"
921 "対応形式:PNG、JPEG、TGA"),
922 )
923 return
925 self.import_dropped_images(
926 [str(path) for path in paths],
927 layer_name=folder_path.name,
928 )
931 def import_images_raw_dialog(self):
932 """変換せず(色数削減なしで)画像を下書きレイヤーへ読み込む。"""
933 paths, _ = QFileDialog.getOpenFileNames(
934 self,
935 tr("変換せず読み込む(下書きレイヤー)"),
936 "",
937 tr("画像 (*.png *.jpg *.jpeg *.tga);;"
938 "PNG (*.png);;JPEG (*.jpg *.jpeg);;TGA (*.tga)"),
939 )
940 if not paths:
941 return
942 self.import_dropped_images_raw(paths)
944 def import_dropped_images_raw(self, paths, layer_name=None):
945 prepared, error = self.prepare_image_import(paths)
946 if not prepared:
947 if error != "__cancelled__":
948 QMessageBox.warning(
949 self,
950 tr("変換せず読み込む"),
951 tr("画像を読み込めませんでした。\n\n{error}").format(error=error),
952 )
953 return
954 estimated_frames = max(1, len(self.canvas.frames) + len(paths))
955 combined_total = max(1, len(paths) + estimated_frames)
956 progress = create_counter(
957 self,
958 tr("変換せず読み込む"),
959 combined_total,
960 tr("画像を読み込んでいます"),
961 )
962 ok = False
963 error = ""
964 try:
965 ok, error = self.canvas.import_image_sequence(
966 paths,
967 lambda value, total, label: update_counter(
968 progress,
969 value,
970 combined_total,
971 tr("{label}(画像 {value}/{total})").format(label=label, value=value, total=total),
972 ),
973 layer_name=layer_name,
974 draft=True,
975 )
976 finally:
977 close_counter(progress)
978 if not ok:
979 QMessageBox.warning(
980 self,
981 tr("変換せず読み込む"),
982 tr("画像を読み込めませんでした。\n\n{error}").format(error=error),
983 )
984 return
985 self.timeline_ops.set_mode("sequence")
986 # 下書きレイヤーは色数を取得しないため、使用色パネルは空にする。
987 self.used_color._refresh_without_delay()
988 self.statusBar().showMessage(
989 tr("{len}枚を下書きレイヤーへ変換せず読み込みました。").format(len=len(paths)),
990 3200,
991 )
993 def resize_doc(self):
994 d=CanvasSizeDialog(constants.CANVAS_WIDTH,constants.CANVAS_HEIGHT,tr("キャンバスサイズの変更"),self)
995 if d.exec():self.canvas.push_doc_undo();self.replace_doc(*d.values(),preserve=True)
996 def replace_doc(self,w,h,preserve=False):
997 old_frames=self.canvas.frames if preserve else None;oldw,oldh=constants.CANVAS_WIDTH,constants.CANVAS_HEIGHT;constants.CANVAS_WIDTH,constants.CANVAS_HEIGHT=w,h
998 if preserve: 998 ↛ 999line 998 didn't jump to line 999 because the condition on line 998 was never true
999 new=[]
1000 for f in old_frames: # pyright: ignore[reportOptionalIterable]
1001 ls=[]
1002 for l in f.layers:
1003 ni=blank_image();p=QPainter(ni);p.drawImage(QRectF(OUTSIDE_MARGIN, OUTSIDE_MARGIN, min(oldw, w), min(oldh, h)), l.image, QRectF(OUTSIDE_MARGIN, OUTSIDE_MARGIN, min(oldw, w), min(oldh, h)));p.end();ls.append(Layer(l.name,ni,l.visible,l.opacity,l.is_paper,l.has_content,l.alpha_locked,l.exposure,l.color_filter_enabled,tuple(l.color_filter_rgb) if l.color_filter_rgb is not None else None,bool(l.is_blank_key),l.sequence_number,bool(l.sequence_only),bool(getattr(l,"is_draft",False)),getattr(l,"cell_name",None)))
1004 new.append(Frame(ls,f.duration))
1005 self.canvas.frames=new
1006 else:
1007 # A brand-new document must not inherit transient interaction state
1008 # from the previous one: an active selection/transform or a running
1009 # playback still references the old (now-discarded) frames and canvas
1010 # size, which produced stale overlays and intermittent errors on the
1011 # fresh document.
1012 if self.canvas._playback_active:
1013 self.timeline.play.setChecked(False)
1014 self.play(False)
1015 self.tween.cancel_transform_or_tween()
1016 self.canvas.clear_selection()
1017 # Reset the frame/layer cursor *before* set_timeline_mode runs: it
1018 # accesses self.canvas.layers (i.e. frames[current_frame]), which
1019 # would be out of range if the previous document left current_frame
1020 # past index 0 of the new single-frame list.
1021 self.canvas.current_frame=0
1022 self.canvas.active_layer_index=0
1023 self.palette.clear_categories()
1024 self.color_chart_ops.clear()
1025 self.canvas.frames=[make_frame()];self.canvas.undo_stack.clear();self.canvas.redo_stack.clear();self.canvas.clear_history_branches();self.timeline_ops.set_mode("sheet")
1026 self.canvas.playback_range_start = 0
1027 self.canvas.playback_range_end = None
1028 self.canvas.clip_studio_source_metadata = None
1029 self.canvas.current_frame=0
1030 self.canvas.active_layer_index=0
1031 if not preserve: 1031 ↛ 1034line 1031 didn't jump to line 1034 because the condition on line 1031 was always true
1032 self.current_project_path = None
1033 self.project.update_title()
1034 self.refresh_ui()
1035 QTimer.singleShot(0, self.canvas.warm_up_brush_runtime)
1036 QTimer.singleShot(0,self.fit_canvas)
1038 def closeEvent(self, event):
1039 """Stop active timers and UI signals before Qt destroys child widgets."""
1040 if not getattr(self, "_closing", False) and not self.project.confirm_save_before_close():
1041 event.ignore()
1042 return
1043 self._closing = True
1044 try:
1045 session_state.save(self)
1046 except OSError:
1047 log.warning("Could not save painting session", exc_info=True)
1048 try:
1049 # A closed window must stop filtering application-wide events;
1050 # otherwise it keeps intercepting input for the rest of the process
1051 # (and leaks across tests that share one QApplication).
1052 remove_app_event_filter(self)
1053 remove_app_event_filter(self.subview)
1054 except RuntimeError:
1055 log.debug("application was deleted during event-filter cleanup", exc_info=True)
1056 try:
1057 self.timer.stop()
1058 self._title_timer.stop()
1059 self._used_color_timer.stop()
1060 self._visible_color_timer.stop()
1061 if getattr(self, "_autosave_timer", None) is not None: 1061 ↛ 1063line 1061 didn't jump to line 1063 because the condition on line 1061 was always true
1062 self._autosave_timer.stop()
1063 self.timeline.blockSignals(True)
1064 self.timeline.table.blockSignals(True)
1065 self.timeline.layer_list.blockSignals(True)
1066 self.canvas.blockSignals(True)
1067 self._split_drop_timer.stop()
1068 self._split_drop_overlay.close()
1069 for skeleton in self._split_drop_skeletons:
1070 skeleton.close()
1071 except RuntimeError:
1072 log.debug("window child was deleted during shutdown", exc_info=True)
1073 # A clean shutdown clears the autosave so we don't prompt to restore
1074 # on the next launch.
1075 self.autosave.clear()
1076 event.accept()
1078 # ------------------------------------------------------------------
1079 # メモ(Issue #25)
1080 # ------------------------------------------------------------------
1081 def set_project_memo(self, text):
1082 """メモの中身を差し替える。開いている窓にも反映する。"""
1083 self.project_memo = str(text or "")
1084 if self._memo_window is not None:
1085 self._memo_window.set_text(self.project_memo)
1087 def show_memo_window(self):
1088 """メモの窓を開く。すでに開いていれば前へ出す。"""
1089 if self._memo_window is None:
1090 self._memo_window = MemoWindow(self)
1091 self._memo_window.set_text(self.project_memo)
1092 self._memo_window.textChanged.connect(self._memo_text_changed)
1093 self._memo_window.show()
1094 self._memo_window.raise_()
1095 self._memo_window.activateWindow()
1096 return self._memo_window
1098 def _memo_text_changed(self, text):
1099 self.project_memo = str(text)
1101 def pressure(self):
1102 d = PressureDialog(
1103 pressure_settings.brush_uses_global(),
1104 pressure_settings.active_preset_name(),
1105 pressure_settings.preset_names(),
1106 pressure_settings.preset,
1107 pressure_settings.brush_settings(),
1108 self,
1109 )
1110 d.editPresetsRequested.connect(
1111 lambda: self.show_preferences("pressure")
1112 )
1113 if d.exec():
1114 pressure_settings.set_brush_uses_global(d.uses_global())
1115 pressure_settings.set_active_preset(d.active_preset())
1116 pressure_settings.set_brush_settings(d.brush_settings())
1117 self.apply_pressure_settings()
1118 if hasattr(self, "preferences_dialog"):
1119 self.preferences_dialog.reload_pressure_presets()
1121 def apply_pressure_settings(self):
1122 """Point the canvas at the pressure setting the brush uses now."""
1123 settings = pressure_settings.effective_settings()
1124 self.canvas.pressure_enabled = settings["enabled"]
1125 self.canvas.pressure_min = settings["minimum"]
1126 self.canvas.pressure_max = settings["maximum"]
1127 self.canvas.pressure_curve_points = settings["points"]
1128 self.canvas.pressure_curve = 1.0
1129 def shortcuts(self):
1130 # アクションパネルのボタン。メニューの操作と同じもの(黒シルエットなど)は
1131 # ここにまとめ、ほかのタブからは外して同じ操作が2か所に出ないようにする。
1132 panel_actions = self.action_panel.shortcut_entries()
1133 in_panel = {id(action) for _name, action in panel_actions}
1135 def without_panel(entries):
1136 return [(name, action) for name, action in entries if id(action) not in in_panel]
1138 categories = [
1139 (tr("ファイル・編集"), without_panel(self.file_edit_actions)),
1140 (tr("ツール"), self.tool_action_list),
1141 (tr("キャンバス操作"), self.canvas_operation_actions),
1142 (tr("ツールコマンド"), without_panel(self.tool_command_actions)),
1143 (tr("アクション"), panel_actions),
1144 (tr("タイムライン"), self.timeline_actions),
1145 ]
1146 if ShortcutDialog(categories, self).exec(): 1146 ↛ 1147line 1146 didn't jump to line 1147 because the condition on line 1146 was never true
1147 self.shortcut_store.save()
1148 def crop_image(self,fi):return self.canvas.composite(fi,True).copy(OUTSIDE_MARGIN,OUTSIDE_MARGIN,constants.CANVAS_WIDTH,constants.CANVAS_HEIGHT)
1149 def save_png(self):
1150 path,_=QFileDialog.getSaveFileName(self,tr("PNG保存"),"frame.png","PNG (*.png)");
1151 if path:self.crop_image(self.canvas.current_frame).save(path if path.lower().endswith('.png') else path+'.png','PNG') # pyright: ignore[reportCallIssue,reportArgumentType] # QImage.save accepts a str format at runtime
1152 def exposure_images(self):
1153 out=[]
1154 for i,f in enumerate(self.canvas.frames):
1155 im=self.crop_image(i)
1156 for _ in range(f.duration):out.append(im.copy())
1157 return out
1158 def save_tga_image(self, image, path):
1159 if PILImage is not None:
1160 rgba=image.convertToFormat(QImage.Format.Format_RGBA8888)
1161 width,height=rgba.width(),rgba.height()
1162 ptr = imaging.qimage_buffer(rgba)
1163 rows=np.frombuffer(ptr,dtype=np.uint8).reshape((height,rgba.bytesPerLine()))
1164 pixels=rows[:,:width*4].reshape((height,width,4)).copy()
1165 pil=PILImage.fromarray(pixels,"RGBA")
1166 pil.save(str(path),format="TGA",compression="tga_rle")
1167 return True
1168 return image.save(str(path),"TGA")
1170 def save_tga(self):
1171 path,_=QFileDialog.getSaveFileName(self,tr("TGA保存"),"frame.tga","TGA (*.tga)")
1172 if not path:return
1173 if not path.lower().endswith(".tga"):path+=".tga"
1174 if not self.save_tga_image(self.crop_image(self.canvas.current_frame),Path(path)):
1175 QMessageBox.warning(self,tr("TGA保存"),tr("TGAを保存できませんでした。Pillowの導入を確認してください。"))
1177 def _sheet_duration(self):
1178 if self.canvas.playback_range_end is not None:
1179 return max(1, int(self.canvas.playback_range_end))
1180 duration = max(1, len(self.canvas.frames))
1181 for column, frame in enumerate(self.canvas.frames):
1182 for layer in frame.layers:
1183 if layer.sequence_only: 1183 ↛ 1184line 1183 didn't jump to line 1184 because the condition on line 1183 was never true
1184 continue
1185 if layer.has_content or layer.is_blank_key:
1186 duration = max(
1187 duration,
1188 column + max(1, int(layer.exposure)),
1189 )
1190 return duration
1193 def play(self, on):
1194 if on:
1195 fps = max(1, int(self.timeline.fps.value()))
1196 self._playback_started_at = time.perf_counter()
1197 self._playback_emitted_steps = 0
1198 self.canvas.set_playback_active(True)
1200 # 本来のフレーム間隔より細かく確認し、遅延時は
1201 # 経過時間に合わせてフレームを追いつかせる。
1202 poll_interval = max(
1203 4,
1204 min(16, int(round(500.0 / fps))),
1205 )
1206 self.timer.start(poll_interval)
1207 else:
1208 self.timer.stop()
1209 self._playback_started_at = None
1210 self._playback_emitted_steps = 0
1211 self.canvas.set_playback_active(False)
1213 # 再生中は省略していたタイムライン選択同期を停止時に1回だけ行う。
1214 self.canvas.selectionChanged.emit()
1215 self.canvas.update()
1217 def advance(self):
1218 if self._playback_started_at is None: 1218 ↛ 1219line 1218 didn't jump to line 1219 because the condition on line 1218 was never true
1219 return
1220 fps = max(1, int(self.timeline.fps.value()))
1221 elapsed = max(
1222 0.0,
1223 time.perf_counter() - self._playback_started_at,
1224 )
1225 expected_steps = int(math.floor(elapsed * fps))
1226 delta = expected_steps - self._playback_emitted_steps
1227 if delta <= 0: 1227 ↛ 1230line 1227 didn't jump to line 1230 because the condition on line 1227 was always true
1228 return
1230 self._playback_emitted_steps = expected_steps
1231 self.canvas.playback_advance(delta)