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
« 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`.
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
13log = get_logger(__name__)
16class KeyFrameMixin(CanvasMembers):
17 def create_blank_key(
18 self,
19 column=None,
20 layer_index=None,
21 ):
22 """○を作成する。
24 開始セル(●/○)を選択した場合:
25 元のコマを残し、露出末尾の次へ同じ長さの○を挿入する。
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
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
45 self.push_doc_undo()
46 self._ensure_frame_count(column + 1)
48 block = self.timeline_block_at(
49 column,
50 layer_index,
51 )
52 blank_exposure = 1
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()
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()
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
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 )
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
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
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()
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()
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()
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
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
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
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()
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
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
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
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