Coverage for paintmaskanimator/models.py: 95%

131 statements  

« 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 

4 

5from PySide6.QtGui import QImage 

6 

7from .utils import blank_image 

8from .timeline_metadata import timeline_metadata 

9 

10 

11class Cell: 

12 """1枚の絵。同じ絵番号のセルはこれを共有する(Issue #31 Phase 1)。 

13 

14 これまで「同じ番号は同じ絵」は、``Layer.image`` が同じ ``QImage`` を指して 

15 いるか(``id()`` が同じか)でしか確かめられなかった。共有を作る 

16 ``coalesce_numbered_images`` と、描く直前に結び直す 

17 ``sync_numbered_image_from_cell`` の2つで支えていて、どこかで片方だけ 

18 差し替わると、同じ番号が別の絵を指す状態が静かにできる。 

19 

20 ``Cell`` を間に置くと、共有は「同じ ``Cell`` を指しているか」というデータに 

21 なる。``layer.image = x`` は ``Cell`` の中身を書き換えるので、共有している 

22 セルにもそのまま出る。 

23 

24 Phase 1 では、共有を**結ぶところだけ**を明示する(``share_cell_with``)。 

25 シート操作(セルを消す・別の絵を置く)が共有を壊さないよう、既定では 

26 レイヤーごとに別の ``Cell`` を持つ。既定を共有側へ寄せるのは Phase 3。 

27 

28 Phase 1 で ``Cell`` が持つのは ``image`` だけ。色塗り前の初期状態 

29 (``initial_image``)はセルごとに復元する Undo があるため、レイヤー側に 

30 残す。まとめるのは Undo を ``(track, number)`` 単位へ移す Phase 4。 

31 

32 設計は docs/sequence-first-data-model.md。 

33 """ 

34 

35 __slots__ = ("image",) 

36 

37 def __init__(self, image=None): 

38 self.image = image if image is not None else blank_image() 

39 

40 

41class Track: 

42 """レイヤー1本ぶんの表示の設定(Issue #31 Phase 2)。 

43 

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 経路があるとそのコマだけ不透明度が違う、という状態になっていた。 

50 

51 同じレイヤーのセルは1つの ``Track`` を共有する。どのコマから変えても 

52 レイヤー全体に効くので、写す処理が要らなくなる。 

53 

54 設計は docs/sequence-first-data-model.md。 

55 """ 

56 

57 __slots__ = ( 

58 "name", "visible", "opacity", "is_paper", "is_draft", "alpha_locked", 

59 "color_filter_enabled", "color_filter_rgb", 

60 ) 

61 

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 

75 

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 ) 

83 

84 

85#: ``Track`` が持つ(=レイヤー共通の)設定。 

86TRACK_FIELDS = Track.__slots__ 

87 

88 

89def _track_property(name): 

90 """``Layer`` の表示設定を ``Track`` 越しに読み書きする。""" 

91 

92 def get(layer): 

93 return getattr(layer._track, name) 

94 

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) 

100 

101 return property(get, set_value) 

102 

103 

104def _cell_property(name): 

105 """``Layer.image`` を ``Cell`` 越しに読み書きする。""" 

106 

107 def get(layer): 

108 return getattr(layer._cell, name) 

109 

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) 

116 

117 return property(get, set_value) 

118 

119 

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 

126 

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) 

132 

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 

158 

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 

181 

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 

191 

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 

198 

199 @property 

200 def track_id(self): 

201 """共有の見分けに使う値。同じなら同じレイヤーの設定を見ている。""" 

202 return id(self._track) 

203 

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 

209 

210 def detach_track(self): 

211 """共有をやめ、いまの設定を自分だけのものにする。""" 

212 self.__dict__["_track"] = self._track.copy() 

213 

214 @property 

215 def cell_id(self): 

216 """共有の見分けに使う値。同じなら同じ絵を指している。""" 

217 return id(self._cell) 

218 

219 def share_cell_with(self, other): 

220 """``other`` と同じ絵を指すようにする。 

221 

222 以後はどちらに書き込んでも両方に出る。``coalesce_numbered_images`` が 

223 同じ絵番号のセルを結ぶときに使う。 

224 """ 

225 if other is self: 

226 return 

227 self.__dict__["_cell"] = other._cell 

228 

229 def detach_cell(self): 

230 """共有をやめ、いまの絵を自分だけのものにする。 

231 

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 ) 

239 

240 

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 

250 

251 

252@dataclass 

253class Frame: 

254 layers: List[Layer] 

255 duration: int = 1 

256 

257 def clone(self): 

258 return Frame([x.clone() for x in self.layers], self.duration) 

259 

260 

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 

271 

272 

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) 

280 

281 

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