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

1"""Playback frame compositing/caching for :class:`PaintCanvas`. 

2 

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 

13 

14log = get_logger(__name__) 

15 

16 

17class PlaybackMixin(CanvasMembers): 

18 def _build_playback_key_map(self): 

19 """各フレームで表示するキーフレームを事前解決する。""" 

20 from .timeline_resolution import resolve_exposures 

21 

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 ] 

26 

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

38 

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

49 

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) 

57 

58 def _playback_cache_dimensions(self, target_rect): 

59 """再生用キャッシュの大きさ(実際の画面の画素数)。 

60 

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 ) 

79 

80 def playback_frame_image(self, frame_index, target_rect): 

81 if not (0 <= frame_index < len(self.frames)): 

82 return None 

83 

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 

105 

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 ) 

123 

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 

140 

141 layer = self.frames[ 

142 key_frame 

143 ].layers[layer_index] 

144 if not layer.visible: 

145 continue 

146 

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) 

157 

158 painter.end() 

159 

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