Coverage for paintmaskanimator/models.py: 95%
131 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
1import copy
2from dataclasses import dataclass
3from typing import ClassVar, List, Optional
5from PySide6.QtGui import QImage
7from .utils import blank_image
8from .timeline_metadata import timeline_metadata
11class Cell:
12 """1枚の絵。同じ絵番号のセルはこれを共有する(Issue #31 Phase 1)。
14 これまで「同じ番号は同じ絵」は、``Layer.image`` が同じ ``QImage`` を指して
15 いるか(``id()`` が同じか)でしか確かめられなかった。共有を作る
16 ``coalesce_numbered_images`` と、描く直前に結び直す
17 ``sync_numbered_image_from_cell`` の2つで支えていて、どこかで片方だけ
18 差し替わると、同じ番号が別の絵を指す状態が静かにできる。
20 ``Cell`` を間に置くと、共有は「同じ ``Cell`` を指しているか」というデータに
21 なる。``layer.image = x`` は ``Cell`` の中身を書き換えるので、共有している
22 セルにもそのまま出る。
24 Phase 1 では、共有を**結ぶところだけ**を明示する(``share_cell_with``)。
25 シート操作(セルを消す・別の絵を置く)が共有を壊さないよう、既定では
26 レイヤーごとに別の ``Cell`` を持つ。既定を共有側へ寄せるのは Phase 3。
28 Phase 1 で ``Cell`` が持つのは ``image`` だけ。色塗り前の初期状態
29 (``initial_image``)はセルごとに復元する Undo があるため、レイヤー側に
30 残す。まとめるのは Undo を ``(track, number)`` 単位へ移す Phase 4。
32 設計は docs/sequence-first-data-model.md。
33 """
35 __slots__ = ("image",)
37 def __init__(self, image=None):
38 self.image = image if image is not None else blank_image()
41class Track:
42 """レイヤー1本ぶんの表示の設定(Issue #31 Phase 2)。
44 ``name`` / ``visible`` / ``opacity`` / ``is_paper`` / ``is_draft`` /
45 ``alpha_locked`` / ``color_filter_enabled`` / ``color_filter_rgb`` は、
46 本来レイヤーの設定なのに ``Layer``(=1コマ分のセル)が各自で持っていた。
47 そのため、セルを作るたび・コマを動かすたびに
48 ``_copy_display_properties`` が8個の値を写して回る必要があり、写し忘れた
49 経路があるとそのコマだけ不透明度が違う、という状態になっていた。
51 同じレイヤーのセルは1つの ``Track`` を共有する。どのコマから変えても
52 レイヤー全体に効くので、写す処理が要らなくなる。
54 設計は docs/sequence-first-data-model.md。
55 """
57 __slots__ = (
58 "name", "visible", "opacity", "is_paper", "is_draft", "alpha_locked",
59 "color_filter_enabled", "color_filter_rgb",
60 )
62 def __init__(
63 self, name="", visible=True, opacity=1.0, is_paper=False,
64 is_draft=False, alpha_locked=False, color_filter_enabled=False,
65 color_filter_rgb=None,
66 ):
67 self.name = name
68 self.visible = visible
69 self.opacity = opacity
70 self.is_paper = is_paper
71 self.is_draft = is_draft
72 self.alpha_locked = alpha_locked
73 self.color_filter_enabled = color_filter_enabled
74 self.color_filter_rgb = color_filter_rgb
76 def copy(self):
77 return Track(
78 self.name, self.visible, self.opacity, self.is_paper,
79 self.is_draft, self.alpha_locked, self.color_filter_enabled,
80 tuple(self.color_filter_rgb)
81 if self.color_filter_rgb is not None else None,
82 )
85#: ``Track`` が持つ(=レイヤー共通の)設定。
86TRACK_FIELDS = Track.__slots__
89def _track_property(name):
90 """``Layer`` の表示設定を ``Track`` 越しに読み書きする。"""
92 def get(layer):
93 return getattr(layer._track, name)
95 def set_value(layer, value):
96 track = layer.__dict__.get("_track")
97 if track is None:
98 track = layer.__dict__["_track"] = Track()
99 setattr(track, name, value)
101 return property(get, set_value)
104def _cell_property(name):
105 """``Layer.image`` を ``Cell`` 越しに読み書きする。"""
107 def get(layer):
108 return getattr(layer._cell, name)
110 def set_value(layer, value):
111 # dataclass の __init__ からも呼ばれるので、ここで作る。
112 cell = layer.__dict__.get("_cell")
113 if cell is None:
114 cell = layer.__dict__["_cell"] = Cell()
115 setattr(cell, name, value)
117 return property(get, set_value)
120@timeline_metadata
121@dataclass
122class Layer:
123 # Exposure lookup also runs during scrubbing. Track direct metadata edits,
124 # including undo/import paths, without rescanning every cell on each lookup.
125 timeline_revision: ClassVar[int] = 0
127 def __setattr__(self, name, value):
128 if name in ("has_content", "is_blank_key", "exposure"):
129 if getattr(self, name, None) != value:
130 Layer.timeline_revision += 1
131 object.__setattr__(self, name, value)
133 name: str
134 image: QImage
135 visible: bool = True
136 opacity: float = 1.0
137 is_paper: bool = False
138 has_content: bool = False
139 alpha_locked: bool = False
140 exposure: int = 1
141 color_filter_enabled: bool = False
142 color_filter_rgb: Optional[tuple] = None
143 is_blank_key: bool = False
144 sequence_number: Optional[int] = None
145 sequence_only: bool = False
146 # 下書きレイヤー: 色数削減の対象外で、読み込んだ画素をそのまま表示・保存する。
147 is_draft: bool = False
148 # 外部タイムライン由来のセル名。数値セルは sequence_number と併記する。
149 cell_name: Optional[str] = None
150 # あとから直せるトゥイーン。キーのセルに設定(id・長さ・変形の形)を持ち、
151 # 中割りのセルは tween_member に同じ id を持つ(tween_groups.py)。
152 tween: Optional[dict] = None
153 tween_member: Optional[str] = None
154 # 色塗り前の初期状態(カットフォルダーから読み込んだままの絵など)。自動塗りは
155 # ここから黒の主線と色トレス線を取り出して部分を分ける。塗りかけの色や、仕上げ色で
156 # 塗りつぶした色トレス線に惑わされないように保持する。比較表示にも使う。
157 initial_image: Optional[QImage] = None
159 def clone(self):
160 # QImage は暗黙共有。QImage(image) は画素をコピーせず参照を共有し、
161 # どちらかに書き込んだ時点(QPainter や bits())で初めて複製される。
162 # 大きなキャンバスでは Undo の文書スナップショットやコマの複製で
163 # 全レイヤーを即座にコピーすると数百MB単位の確保になるため。
164 copied = Layer(
165 self.name, QImage(self.image), self.visible, self.opacity,
166 self.is_paper, self.has_content, self.alpha_locked, self.exposure,
167 self.color_filter_enabled,
168 tuple(self.color_filter_rgb) if self.color_filter_rgb is not None else None,
169 bool(self.is_blank_key),
170 self.sequence_number,
171 bool(self.sequence_only),
172 bool(self.is_draft),
173 str(self.cell_name) if self.cell_name is not None else None,
174 copy.deepcopy(self.tween) if self.tween is not None else None,
175 self.tween_member,
176 QImage(self.initial_image) if self.initial_image is not None else None,
177 )
178 # Undo のスナップショットが、あとの表示設定の変更で汚れないようにする。
179 copied.detach_track()
180 return copied
182 # ------------------------------------------------------------------
183 # 絵の共有(Issue #31 Phase 1)
184 # ------------------------------------------------------------------
185 @property
186 def _cell(self):
187 cell = self.__dict__.get("_cell")
188 if cell is None: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 cell = self.__dict__["_cell"] = Cell()
190 return cell
192 @property
193 def _track(self):
194 track = self.__dict__.get("_track")
195 if track is None: 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true
196 track = self.__dict__["_track"] = Track()
197 return track
199 @property
200 def track_id(self):
201 """共有の見分けに使う値。同じなら同じレイヤーの設定を見ている。"""
202 return id(self._track)
204 def share_track_with(self, other):
205 """``other`` と同じレイヤー設定を見るようにする。"""
206 if other is self: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true
207 return
208 self.__dict__["_track"] = other._track
210 def detach_track(self):
211 """共有をやめ、いまの設定を自分だけのものにする。"""
212 self.__dict__["_track"] = self._track.copy()
214 @property
215 def cell_id(self):
216 """共有の見分けに使う値。同じなら同じ絵を指している。"""
217 return id(self._cell)
219 def share_cell_with(self, other):
220 """``other`` と同じ絵を指すようにする。
222 以後はどちらに書き込んでも両方に出る。``coalesce_numbered_images`` が
223 同じ絵番号のセルを結ぶときに使う。
224 """
225 if other is self:
226 return
227 self.__dict__["_cell"] = other._cell
229 def detach_cell(self):
230 """共有をやめ、いまの絵を自分だけのものにする。
232 シート操作(このコマだけ別の絵にする・消す)で使う。``QImage`` は
233 暗黙共有なので、ここでは画素を複製しない。
234 """
235 current = self._cell
236 self.__dict__["_cell"] = Cell(
237 QImage(current.image) if current.image is not None else None
238 )
241# @dataclass が image の既定値を拾ったあとで差し替える。timeline_metadata と
242# 同じ手口(デコレータの順に依存しないよう、ここで行う)。型チェッカーからは
243# dataclass のフィールドに見えるので、代入だけ無視させる。
244for _field, _descriptor in (
245 ("image", _cell_property("image")),
246 *((name, _track_property(name)) for name in TRACK_FIELDS),
247):
248 setattr(Layer, _field, _descriptor)
249del _field, _descriptor
252@dataclass
253class Frame:
254 layers: List[Layer]
255 duration: int = 1
257 def clone(self):
258 return Frame([x.clone() for x in self.layers], self.duration)
261def default_layer_name(index):
262 """Cel-style default name for the layer at ``index``: A..Z, AA, AB, ..."""
263 index = max(0, int(index))
264 name = ""
265 while True:
266 index, remainder = divmod(index, 26)
267 name = chr(ord("A") + remainder) + name
268 if index == 0:
269 return name
270 index -= 1
273def next_layer_name(existing):
274 """The first default name not already used by ``existing`` names."""
275 used = {str(name).strip().casefold() for name in existing}
276 index = 0
277 while default_layer_name(index).casefold() in used:
278 index += 1
279 return default_layer_name(index)
282def make_frame(layer_names=None):
283 names = layer_names or [default_layer_name(0)]
284 return Frame([Layer(name, blank_image()) for name in names])