Coverage for paintmaskanimator/canvas_key_frame.py: 70%

154 statements  

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

1"""Key-frame creation / navigation / resolution for :class:`PaintCanvas`. 

2 

3Split out of ``canvas.py`` as a mixin. These methods create blank keys, step to 

4the previous/next key, resolve which key backs a given exposure cell, and ensure 

5the active cell is an editable key before painting. They run against a live 

6``PaintCanvas`` instance. 

7""" 

8# (nothing was used) 

9from ._canvas_members import CanvasMembers 

10from .utils import blank_image 

11from .logging_setup import get_logger 

12 

13log = get_logger(__name__) 

14 

15 

16class KeyFrameMixin(CanvasMembers): 

17 def create_blank_key( 

18 self, 

19 column=None, 

20 layer_index=None, 

21 ): 

22 """○を作成する。 

23 

24 開始セル(●/○)を選択した場合: 

25 元のコマを残し、露出末尾の次へ同じ長さの○を挿入する。 

26 

27 露出途中(ー/│)を選択した場合: 

28 全体の長さを変えず、選択位置から後半を○へ分割する。 

29 """ 

30 if column is None: 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true

31 column = self.current_frame 

32 if layer_index is None: 32 ↛ 33line 32 didn't jump to line 33 because the condition on line 32 was never true

33 layer_index = self.active_layer_index 

34 

35 column = max(0, int(column)) 

36 layer_index = int(layer_index) 

37 if not self.frames: 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true

38 return False 

39 if not ( 39 ↛ 43line 39 didn't jump to line 43 because the condition on line 39 was never true

40 0 <= layer_index 

41 < len(self.frames[0].layers) 

42 ): 

43 return False 

44 

45 self.push_doc_undo() 

46 self._ensure_frame_count(column + 1) 

47 

48 block = self.timeline_block_at( 

49 column, 

50 layer_index, 

51 ) 

52 blank_exposure = 1 

53 

54 if block is not None: 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true

55 _kind, start, exposure = block 

56 start = int(start) 

57 exposure = max(1, int(exposure)) 

58 source = self.frames[ 

59 start 

60 ].layers[layer_index] 

61 template = source.clone() 

62 

63 if column == start: 

64 # ●/○の開始セルを選択: 

65 # 元のコマを保持し、❘の次へ同じ長さの○を挿入する。 

66 insertion_column = start + exposure 

67 blank_exposure = exposure 

68 self._shift_timeline_layer_right( 

69 layer_index, 

70 insertion_column, 

71 blank_exposure, 

72 ) 

73 self._ensure_frame_count( 

74 insertion_column + blank_exposure 

75 ) 

76 column = insertion_column 

77 else: 

78 # ー/│を選択: 

79 # 露出全体の長さは変えず、選択位置で前後に分割する。 

80 # 

81 # 例: 

82 # ●ーーーーー│ 

83 # ↓ 

84 # ●ーー○ーー│ 

85 split_offset = max( 

86 1, 

87 column - start, 

88 ) 

89 split_offset = min( 

90 split_offset, 

91 exposure - 1, 

92 ) 

93 blank_exposure = max( 

94 1, 

95 exposure - split_offset, 

96 ) 

97 source.exposure = max( 

98 1, 

99 split_offset, 

100 ) 

101 column = start + split_offset 

102 self._ensure_frame_count( 

103 column + blank_exposure 

104 ) 

105 else: 

106 # 未使用セル上では、その位置へ○を作る。 

107 # 直前の明示コマがある場合は、○の直前まで露出を伸ばす。 

108 self._ensure_frame_count(column + 1) 

109 target = self.frames[ 

110 column 

111 ].layers[layer_index] 

112 template = target.clone() 

113 

114 previous_start = None 

115 for candidate in range( 

116 column - 1, 

117 -1, 

118 -1, 

119 ): 

120 layer = self.frames[ 

121 candidate 

122 ].layers[layer_index] 

123 if ( 

124 layer.has_content 

125 or getattr( 

126 layer, 

127 "is_blank_key", 

128 False, 

129 ) 

130 ): 

131 previous_start = candidate 

132 template = layer.clone() 

133 break 

134 

135 if previous_start is not None: 

136 previous = self.frames[ 

137 previous_start 

138 ].layers[layer_index] 

139 previous.exposure = max( 

140 1, 

141 column - previous_start, 

142 ) 

143 

144 target = self.frames[ 

145 column 

146 ].layers[layer_index] 

147 target.image = blank_image() 

148 target.visible = bool(template.visible) 

149 target.opacity = float(template.opacity) 

150 target.alpha_locked = bool( 

151 template.alpha_locked 

152 ) 

153 target.color_filter_enabled = bool( 

154 template.color_filter_enabled 

155 ) 

156 target.color_filter_rgb = ( 

157 tuple(template.color_filter_rgb) 

158 if template.color_filter_rgb 

159 is not None 

160 else None 

161 ) 

162 target.has_content = False 

163 target.is_blank_key = True 

164 target.exposure = blank_exposure 

165 

166 self.current_frame = column 

167 self.active_layer_index = layer_index 

168 self._cell_structure_dirty = True 

169 self._onion_cache.clear() 

170 self.changed.emit() 

171 self.selectionChanged.emit() 

172 self.update() 

173 return True 

174 

175 def previous_key_frame(self): 

176 layer_index = self.active_layer_index 

177 keys = self.timeline_step_columns(layer_index) 

178 previous = [i for i in keys if i < self.current_frame] 

179 if previous: 

180 self.current_frame = previous[-1] 

181 elif keys: 

182 self.current_frame = keys[-1] 

183 self.selectionChanged.emit() 

184 self.update() 

185 

186 def next_key_frame(self): 

187 layer_index = self.active_layer_index 

188 keys = self.timeline_step_columns(layer_index) 

189 following = [i for i in keys if i > self.current_frame] 

190 if following: 

191 self.current_frame = following[0] 

192 elif keys: 

193 self.current_frame = keys[0] 

194 self.selectionChanged.emit() 

195 self.update() 

196 

197 def select_exposure(self, column, visual_row): 

198 self.current_frame = max(0, min(int(column), len(self.frames) - 1)) 

199 layer_count = len(self.layers) 

200 if layer_count: 200 ↛ 203line 200 didn't jump to line 203 because the condition on line 200 was always true

201 visual_row = max(0, min(int(visual_row), layer_count - 1)) 

202 self.active_layer_index = layer_count - 1 - visual_row 

203 self.selectionChanged.emit() 

204 self.update() 

205 

206 def resolve_key_frame(self, column, layer_index): 

207 if not self.frames: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true

208 return None 

209 from .models import Layer 

210 from .timeline_resolution import resolve_exposures 

211 

212 column = max(0, min(int(column), len(self.frames) - 1)) 

213 signature = (id(self.frames), len(self.frames), Layer.timeline_revision) 

214 if getattr(self, "_exposure_index_signature", None) != signature: 

215 self._exposure_index = resolve_exposures(self.frames) 

216 self._exposure_index_signature = signature 

217 if not 0 <= layer_index < self._exposure_index.shape[1]: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 return None 

219 key = int(self._exposure_index[column, layer_index]) 

220 return key if key >= 0 else None 

221 

222 def ensure_editable_key(self): 

223 """保持セルでは露出元を編集し、未使用/○は内容キーへ変換する。""" 

224 block = self.timeline_block_at( 

225 self.current_frame, self.active_layer_index, 

226 ) 

227 if block is not None and self.current_frame > block[1]: 

228 # 保持区間は同じセル。編集対象とUndoの保存先を露出元へ揃え、 

229 # 加筆のたびに別のセルや番号が作られるのを防ぐ。 

230 self.current_frame = int(block[1]) 

231 self.selectionChanged.emit() 

232 layer = self.active_layer 

233 if layer.has_content: 

234 if layer.sequence_number is not None: 234 ↛ 241line 234 didn't jump to line 241 because the condition on line 234 was always true

235 # 描画開始前から全参照を同じ画像へ結び、ストローク中も 

236 # 同番号の画像が分離しないようにする。 

237 self.sync_numbered_image_from_cell( 

238 self.current_frame, 

239 self.active_layer_index, 

240 ) 

241 if layer.is_blank_key: 

242 # 連番の空セルへ描画した旧データでは両方のフラグが 

243 # Trueになり得る。内容キーとして即時修復する。 

244 layer.is_blank_key = False 

245 layer.sequence_only = False 

246 self._cell_structure_dirty = True 

247 return True 

248 return False 

249 

250 # Painting during playback can turn an unused/blank cell into a key. 

251 # Pixel undo is captured after this conversion, so retain the timeline 

252 # state separately to make the cell creation itself reversible too. 

253 if self._playback_active: 

254 self.push_doc_undo() 

255 

256 current = int(self.current_frame) 

257 layer_index = int(self.active_layer_index) 

258 block = self.timeline_block_at(current, layer_index) 

259 retained_number = layer.sequence_number 

260 

261 if block is not None: 

262 kind, start, exposure = block 

263 source_layer = self.frames[start].layers[layer_index] 

264 copied = source_layer.clone() 

265 old_end = start + exposure - 1 

266 

267 layer.visible = bool(copied.visible) 

268 layer.opacity = float(copied.opacity) 

269 layer.alpha_locked = bool(copied.alpha_locked) 

270 layer.color_filter_enabled = bool( 

271 copied.color_filter_enabled 

272 ) 

273 layer.color_filter_rgb = ( 

274 tuple(copied.color_filter_rgb) 

275 if copied.color_filter_rgb is not None 

276 else None 

277 ) 

278 layer.image = ( 

279 copied.image.copy() 

280 if kind == "content" 

281 else blank_image() 

282 ) 

283 layer.exposure = max(1, old_end - current + 1) 

284 else: 

285 # An uncreated cell already owns a transparent image. Keep it 

286 # instead of allocating and clearing another full-canvas image at 

287 # the instant the first stroke begins. 

288 layer.exposure = 1 

289 

290 layer.has_content = True 

291 layer.is_blank_key = False 

292 if self.timeline_mode == "sheet": 292 ↛ 305line 292 didn't jump to line 305 because the condition on line 292 was always true

293 if retained_number is not None: 

294 layer.sequence_number = int(retained_number) 

295 else: 

296 existing_numbers = [ 

297 int(seq) 

298 for frame in self.frames 

299 if ( 

300 frame.layers[layer_index].has_content 

301 and (seq := frame.layers[layer_index].sequence_number) is not None 

302 ) 

303 ] 

304 layer.sequence_number = max(existing_numbers, default=0) + 1 

305 self._cell_structure_dirty = True 

306 return True