Coverage for paintmaskanimator/canvas_playback.py: 100%
75 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"""Playback frame compositing/caching for :class:`PaintCanvas`.
3Split out of ``canvas.py`` as a mixin. These methods build the frame->key map
4used during playback, advance the playback position, and composite/cache the
5image shown for each played-back frame. They run against a live ``PaintCanvas``
6instance.
7"""
8from PySide6.QtCore import QRectF, Qt
9from PySide6.QtGui import QImage, QPainter
10from ._canvas_members import CanvasMembers
11from .utils import workspace_size
12from .logging_setup import get_logger
14log = get_logger(__name__)
17class PlaybackMixin(CanvasMembers):
18 def _build_playback_key_map(self):
19 """各フレームで表示するキーフレームを事前解決する。"""
20 from .timeline_resolution import resolve_exposures
22 self._playback_resolved_keys = [
23 [int(key) if key >= 0 else None for key in row]
24 for row in resolve_exposures(self.frames)
25 ]
27 def set_playback_active(self, active):
28 self._playback_active = bool(active)
29 self._playback_frame_cache.clear()
30 if self._playback_active:
31 start, end = self.playback_bounds()
32 if not start <= self.current_frame < end:
33 self.current_frame = start
34 self._build_playback_key_map()
35 else:
36 self._playback_resolved_keys = []
37 self.update()
39 def playback_advance(self, steps=1):
40 """UI全体を再構築せず、再生フレームだけを進める。"""
41 if not self.frames:
42 return
43 start, end = self.playback_bounds()
44 span = max(1, end - start)
45 self.current_frame = start + (
46 self.current_frame - start + max(1, int(steps))
47 ) % span
48 self.update()
50 def playback_bounds(self):
51 """Return the valid inclusive/exclusive playback range."""
52 count = max(1, len(self.frames))
53 start = max(0, min(int(self.playback_range_start), count - 1))
54 requested_end = self.playback_range_end
55 end = count if requested_end is None else max(start + 1, int(requested_end))
56 return start, min(end, count)
58 def _playback_cache_dimensions(self, target_rect):
59 """再生用キャッシュの大きさ(実際の画面の画素数)。
61 以前は見えている大きさ(論理ピクセル)で作り、さらに 1920×1080 で
62 頭打ちにしていたので、Retina では半分の解像度になり、再生中だけ
63 ぼやけていた。画面の倍率を掛けた画素数で作り、元の絵より大きくは
64 しない(それ以上は表示のときに拡大すればよい)。
65 """
66 ratio = max(1.0, float(self.devicePixelRatioF()))
67 source_width, source_height = workspace_size()
68 width = max(1, int(round(target_rect.width() * ratio)))
69 height = max(1, int(round(target_rect.height() * ratio)))
70 scale = min(
71 1.0,
72 source_width / float(width),
73 source_height / float(height),
74 )
75 return (
76 max(1, int(round(width * scale))),
77 max(1, int(round(height * scale))),
78 )
80 def playback_frame_image(self, frame_index, target_rect):
81 if not (0 <= frame_index < len(self.frames)):
82 return None
84 cache_width, cache_height = (
85 self._playback_cache_dimensions(target_rect)
86 )
87 cache_key = (
88 int(frame_index),
89 int(cache_width),
90 int(cache_height),
91 int(self.active_layer_index),
92 bool(self.flip_horizontal),
93 bool(getattr(self, "flip_vertical", False)),
94 bool(self.silhouette_non_background),
95 # 比較表示の有無で中身が変わるので、今の絵のコマを使い回さない。
96 bool(self.show_initial_state),
97 self.view_preview.key(),
98 )
99 cached = self._playback_frame_cache.get(cache_key)
100 if cached is not None:
101 # 最近使ったフレームを末尾へ移す簡易LRU。
102 self._playback_frame_cache.pop(cache_key, None)
103 self._playback_frame_cache[cache_key] = cached
104 return cached
106 image = QImage(
107 cache_width,
108 cache_height,
109 QImage.Format.Format_ARGB32_Premultiplied,
110 )
111 image.fill(Qt.GlobalColor.transparent)
112 painter = QPainter(image)
113 painter.setRenderHint(
114 QPainter.RenderHint.SmoothPixmapTransform,
115 False,
116 )
117 destination = QRectF(
118 0.0,
119 0.0,
120 float(cache_width),
121 float(cache_height),
122 )
124 layer_count = len(self.frames[frame_index].layers)
125 resolved_row = (
126 self._playback_resolved_keys[frame_index]
127 if frame_index < len(self._playback_resolved_keys)
128 else []
129 )
130 for layer_index in range(layer_count):
131 key_frame = (
132 resolved_row[layer_index]
133 if layer_index < len(resolved_row)
134 else self.resolve_key_frame(
135 frame_index, layer_index
136 )
137 )
138 if key_frame is None:
139 continue
141 layer = self.frames[
142 key_frame
143 ].layers[layer_index]
144 if not layer.visible:
145 continue
147 draw_image = self.view_layer_image(
148 layer, layer_index,
149 self._layer_display_source(layer, layer_index),
150 )
151 if self.flip_horizontal or getattr(self, "flip_vertical", False):
152 draw_image = draw_image.mirrored(
153 self.flip_horizontal, getattr(self, "flip_vertical", False)
154 )
155 painter.setOpacity(float(layer.opacity))
156 painter.drawImage(destination, draw_image)
158 painter.end()
160 self._playback_frame_cache[cache_key] = image
161 while (
162 len(self._playback_frame_cache)
163 > self._playback_cache_limit
164 ):
165 oldest_key = next(iter(self._playback_frame_cache))
166 self._playback_frame_cache.pop(oldest_key, None)
167 return image