Coverage for paintmaskanimator/clip_animation.py: 86%

583 statements  

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

1"""CLIP animation import using clipfile-rs through the native PyO3 bridge. 

2 

3File/container, SQLite, animation curves and raster decoding belong to clipfile. 

4This module adapts decoded data to Qt and flattens cel layers for PMA. 

5""" 

6from __future__ import annotations 

7 

8import os 

9from dataclasses import dataclass 

10from pathlib import Path 

11from typing import Callable, Optional 

12 

13import numpy as np 

14from PySide6.QtCore import QPoint 

15from PySide6.QtGui import QColor, QImage, QPainter 

16 

17from . import native 

18from .i18n import tr 

19from .constants import ( 

20 MAX_IMAGE_DIMENSION, MAX_PROJECT_DECODED_PIXELS, MAX_PROJECT_FRAMES, 

21 MAX_PROJECT_LAYER_CELLS, MAX_PROJECT_LAYERS, MAX_PROJECT_IMAGE_BYTES, 

22) 

23from .imaging import qimage_buffer, qimage_rgba_array, rgba_array_to_qimage 

24from .models import Frame, Layer 

25 

26 

27class ClipImportError(ValueError): 

28 """A safe, user-displayable failure while reading a .clip file.""" 

29 

30 

31@dataclass(frozen=True) 

32class ClipCelKey: 

33 frame: int 

34 tag: str 

35 

36 

37@dataclass(frozen=True) 

38class ClipAnimationLayer: 

39 name: str 

40 keys: tuple[ClipCelKey, ...] 

41 cell_images: dict[str, QImage] 

42 

43 

44@dataclass(frozen=True) 

45class ClipAnimationDocument: 

46 source_name: str 

47 timeline_name: str 

48 width: int 

49 height: int 

50 fps: float 

51 start_frame: int 

52 end_frame: int 

53 layers: tuple[ClipAnimationLayer, ...] 

54 frames: tuple[Frame, ...] 

55 

56 @property 

57 def folder_count(self) -> int: 

58 return len(self.layers) 

59 

60 @property 

61 def frame_count(self) -> int: 

62 return len(self.frames) 

63 

64 def source_metadata(self) -> dict: 

65 return { 

66 "file_name": self.source_name, 

67 "timeline_name": self.timeline_name, 

68 "fps": float(self.fps), 

69 "start_frame": int(self.start_frame), 

70 "end_frame": int(self.end_frame), 

71 } 

72 

73 

74def _row_number(row: dict, name: str, default: int = 0) -> int: 

75 value = row[name] if name in row.keys() else default 

76 return default if value is None else int(value) 

77 

78 

79def _csp_color_byte(value: int | float | str | None) -> int: 

80 if value is None: 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true

81 return 0 

82 component = max(0, int(value)) 

83 if component <= 255: 

84 return component 

85 return min(255, component >> 24) 

86 

87 

88def _single_channel_layer_color(layer: dict) -> tuple[int, int, int]: 

89 if _row_number(layer, "DrawColorEnable"): 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true

90 names = ("DrawColorMainRed", "DrawColorMainGreen", "DrawColorMainBlue") 

91 elif _row_number(layer, "LayerUsePaletteColor"): 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true

92 names = ("LayerPaletteRed", "LayerPaletteGreen", "LayerPaletteBlue") 

93 else: 

94 return (0, 0, 0) 

95 red, green, blue = ( 

96 _csp_color_byte(layer[name] if name in layer.keys() else 0) 

97 for name in names 

98 ) 

99 return (red, green, blue) 

100 

101 

102def _blank_canvas(width: int, height: int) -> QImage: 

103 image = QImage( 

104 width, height, QImage.Format.Format_ARGB32_Premultiplied 

105 ) 

106 image.fill(QColor(0, 0, 0, 0)) 

107 return image 

108 

109 

110def _apply_layer_color(image: QImage, layer: dict) -> QImage: 

111 """CSP layer color maps black to main and white to sub, retaining alpha.""" 

112 if not _row_number(layer, "DrawColorEnable"): 

113 return image 

114 main = np.array([_csp_color_byte(layer.get(f"DrawColorMain{c}", 0)) 

115 for c in ("Red", "Green", "Blue")], dtype=np.uint32) 

116 if image.format() == QImage.Format.Format_ARGB32_Premultiplied: 116 ↛ 129line 116 didn't jump to line 129 because the condition on line 116 was always true

117 packed = np.frombuffer(qimage_buffer(image, const=True), dtype=np.uint32) 

118 if not np.any(packed & 0x00FFFFFF): 

119 # Line-art caches are commonly black + alpha even when CSP shows 

120 # a layer color. Tint them directly, preserving antialias alpha. 

121 result = QImage(image) 

122 painter = QPainter(result) 

123 try: 

124 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) 

125 painter.fillRect(result.rect(), QColor(*(int(c) for c in main))) 

126 finally: 

127 painter.end() 

128 return result 

129 sub = np.array([_csp_color_byte(layer.get(f"DrawColorSub{c}", 255)) 

130 for c in ("Red", "Green", "Blue")], dtype=np.uint32) 

131 rgba = qimage_rgba_array(image) 

132 gray = ((rgba[..., 0].astype(np.uint32) * 299 

133 + rgba[..., 1].astype(np.uint32) * 587 

134 + rgba[..., 2].astype(np.uint32) * 114 + 500) // 1000).astype(np.uint8) 

135 levels = np.arange(256, dtype=np.uint32)[:, None] 

136 palette = ((main * (255 - levels) + sub * levels + 127) // 255).astype(np.uint8) 

137 rgba[..., :3] = palette[gray] 

138 return rgba_array_to_qimage(rgba) 

139 

140 

141def _native_raster(reader, mipmap_id: int, color=(0, 0, 0)): 

142 """Convert upstream decoded pixels to a detached QImage; no format parsing.""" 

143 width, height, channels, alpha_only, default_white, data = reader.raster(mipmap_id) 

144 pixels = np.frombuffer(data, dtype=np.uint8).reshape(height, width, channels) 

145 rgba = np.empty((height, width, 4), dtype=np.uint8) 

146 if channels == 4: 

147 rgba[:] = pixels 

148 elif alpha_only: 

149 rgba[..., :3] = color 

150 rgba[..., 3] = pixels[..., 0] 

151 else: 

152 rgba[..., :3] = pixels[..., :1] 

153 rgba[..., 3] = pixels[..., 1] if channels == 2 else 255 

154 return rgba_array_to_qimage(rgba), alpha_only, default_white 

155 

156 

157def _render_cached_layer( 

158 reader, 

159 layer: dict, 

160 canvas_width: int, 

161 canvas_height: int, 

162) -> QImage: 

163 """Place a leaf layer's rendered mipmap on the canvas, before opacity.""" 

164 

165 mipmap_id = layer["LayerRenderMipmap"] 

166 if not mipmap_id: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true

167 raise ClipImportError( 

168 tr("セル「{LayerName}」の表示合成画像が.clip内にありません。").format(LayerName=layer['LayerName']) 

169 ) 

170 color = (0, 0, 0) if _row_number(layer, "DrawColorEnable") else _single_channel_layer_color(layer) 

171 raster, _alpha_only, _default_white = _native_raster(reader, mipmap_id, color) 

172 raster = _apply_layer_color(raster, layer) 

173 result = _blank_canvas(canvas_width, canvas_height) 

174 x = _row_number(layer, "LayerOffsetX") + _row_number( 

175 layer, "LayerRenderOffscrOffsetX" 

176 ) 

177 y = _row_number(layer, "LayerOffsetY") + _row_number( 

178 layer, "LayerRenderOffscrOffsetY" 

179 ) 

180 painter = QPainter(result) 

181 try: 

182 painter.drawImage(QPoint(x, y), raster) 

183 finally: 

184 painter.end() 

185 return result 

186 

187 

188def _child_layer_rows( 

189 layers_by_id: dict[int, dict], layer: dict 

190) -> list[dict]: 

191 # CSP's sibling chain runs bottom to top. The compositor consumes children 

192 # top to bottom, grouping clipped layers with the base immediately below. 

193 return [layers_by_id[child_id] for child_id in reversed(layer["children"])] 

194 

195 

196# CLIP STUDIO's ``LayerComposite`` values. The numbering follows the PSD 

197# blend-mode table in ``dobrokot/clip_to_psd``. 

198_BLEND_NORMAL = 0 

199_BLEND_DARKEN = 1 

200_BLEND_MULTIPLY = 2 

201_BLEND_COLOR_BURN = 3 

202_BLEND_LINEAR_BURN = 4 

203_BLEND_SUBTRACT = 5 

204_BLEND_DARKER_COLOR = 6 

205_BLEND_LIGHTEN = 7 

206_BLEND_SCREEN = 8 

207_BLEND_COLOR_DODGE = 9 

208_BLEND_GLOW_DODGE = 10 

209_BLEND_ADD = 11 

210_BLEND_ADD_GLOW = 12 

211_BLEND_LIGHTER_COLOR = 13 

212_BLEND_OVERLAY = 14 

213_BLEND_SOFT_LIGHT = 15 

214_BLEND_HARD_LIGHT = 16 

215_BLEND_VIVID_LIGHT = 17 

216_BLEND_LINEAR_LIGHT = 18 

217_BLEND_PIN_LIGHT = 19 

218_BLEND_HARD_MIX = 20 

219_BLEND_DIFFERENCE = 21 

220_BLEND_EXCLUSION = 22 

221_BLEND_HUE = 23 

222_BLEND_SATURATION = 24 

223_BLEND_COLOR = 25 

224_BLEND_LUMINOSITY = 26 

225_BLEND_PASS_THROUGH = 30 

226_BLEND_DIVIDE = 36 

227 

228 

229def _safe_divide(numerator, denominator, fallback): 

230 with np.errstate(divide="ignore", invalid="ignore"): 

231 result = numerator / denominator 

232 return np.where(denominator > 0, result, fallback) 

233 

234 

235def _luminosity(color: np.ndarray) -> np.ndarray: 

236 return ( 

237 color[..., 0:1] * 0.3 + color[..., 1:2] * 0.59 + color[..., 2:3] * 0.11 

238 ) 

239 

240 

241def _set_luminosity(color: np.ndarray, luminosity: np.ndarray) -> np.ndarray: 

242 color = color + (luminosity - _luminosity(color)) 

243 lum = _luminosity(color) 

244 low = color.min(axis=-1, keepdims=True) 

245 high = color.max(axis=-1, keepdims=True) 

246 color = np.where( 

247 low < 0, lum + (color - lum) * _safe_divide(lum, lum - low, 0.0), color 

248 ) 

249 color = np.where( 

250 high > 1, 

251 lum + (color - lum) * _safe_divide(1 - lum, high - lum, 0.0), 

252 color, 

253 ) 

254 return color 

255 

256 

257def _saturation(color: np.ndarray) -> np.ndarray: 

258 return color.max(axis=-1, keepdims=True) - color.min(axis=-1, keepdims=True) 

259 

260 

261def _set_saturation(color: np.ndarray, saturation: np.ndarray) -> np.ndarray: 

262 low = color.min(axis=-1, keepdims=True) 

263 return _safe_divide((color - low) * saturation, _saturation(color), 0.0) 

264 

265 

266def _color_burn(backdrop, source): 

267 return np.where( 

268 backdrop >= 1, 

269 1.0, 

270 1 - np.minimum(1.0, _safe_divide(1 - backdrop, source, np.inf)), 

271 ) 

272 

273 

274def _color_dodge(backdrop, source): 

275 return np.where( 

276 backdrop <= 0, 

277 0.0, 

278 np.minimum(1.0, _safe_divide(backdrop, 1 - source, np.inf)), 

279 ) 

280 

281 

282def _hard_light(backdrop, source): 

283 doubled = source * 2 

284 return np.where( 

285 source <= 0.5, 

286 backdrop * doubled, 

287 backdrop + (doubled - 1) - backdrop * (doubled - 1), 

288 ) 

289 

290 

291def _soft_light(backdrop, source): 

292 darken = np.where( 

293 backdrop <= 0.25, 

294 ((16 * backdrop - 12) * backdrop + 4) * backdrop, 

295 np.sqrt(backdrop), 

296 ) 

297 return np.where( 

298 source <= 0.5, 

299 backdrop - (1 - 2 * source) * backdrop * (1 - backdrop), 

300 backdrop + (2 * source - 1) * (darken - backdrop), 

301 ) 

302 

303 

304def _blend_colors(mode: int, backdrop: np.ndarray, source: np.ndarray) -> np.ndarray: 

305 """Return B(Cb, Cs) for straight (non-premultiplied) RGB in 0..1.""" 

306 

307 if mode == _BLEND_DARKEN: 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 return np.minimum(backdrop, source) 

309 if mode == _BLEND_MULTIPLY: 

310 return backdrop * source 

311 if mode == _BLEND_COLOR_BURN: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 return _color_burn(backdrop, source) 

313 if mode == _BLEND_LINEAR_BURN: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 return np.maximum(0.0, backdrop + source - 1) 

315 if mode == _BLEND_SUBTRACT: 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 return np.maximum(0.0, backdrop - source) 

317 if mode == _BLEND_DARKER_COLOR: 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true

318 return np.where( 

319 _luminosity(source) < _luminosity(backdrop), source, backdrop 

320 ) 

321 if mode == _BLEND_LIGHTEN: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 return np.maximum(backdrop, source) 

323 if mode == _BLEND_SCREEN: 

324 return backdrop + source - backdrop * source 

325 # Glow dodge and add (glow) also skip CLIP's transparency shape; only 

326 # their colour formula is reproduced here. 

327 if mode in (_BLEND_COLOR_DODGE, _BLEND_GLOW_DODGE): 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true

328 return _color_dodge(backdrop, source) 

329 if mode in (_BLEND_ADD, _BLEND_ADD_GLOW): 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true

330 return np.minimum(1.0, backdrop + source) 

331 if mode == _BLEND_LIGHTER_COLOR: 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true

332 return np.where( 

333 _luminosity(source) > _luminosity(backdrop), source, backdrop 

334 ) 

335 if mode == _BLEND_OVERLAY: 

336 return _hard_light(source, backdrop) 

337 if mode == _BLEND_SOFT_LIGHT: 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 return _soft_light(backdrop, source) 

339 if mode == _BLEND_HARD_LIGHT: 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true

340 return _hard_light(backdrop, source) 

341 if mode == _BLEND_VIVID_LIGHT: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true

342 return np.where( 

343 source <= 0.5, 

344 _color_burn(backdrop, source * 2), 

345 _color_dodge(backdrop, source * 2 - 1), 

346 ) 

347 if mode == _BLEND_LINEAR_LIGHT: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true

348 return np.clip(backdrop + source * 2 - 1, 0.0, 1.0) 

349 if mode == _BLEND_PIN_LIGHT: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 return np.where( 

351 source <= 0.5, 

352 np.minimum(backdrop, source * 2), 

353 np.maximum(backdrop, source * 2 - 1), 

354 ) 

355 if mode == _BLEND_HARD_MIX: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 return np.where(backdrop + source >= 1, 1.0, 0.0) 

357 if mode == _BLEND_DIFFERENCE: 

358 return np.abs(backdrop - source) 

359 if mode == _BLEND_EXCLUSION: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 return backdrop + source - 2 * backdrop * source 

361 if mode == _BLEND_HUE: 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true

362 return _set_luminosity( 

363 _set_saturation(source, _saturation(backdrop)), 

364 _luminosity(backdrop), 

365 ) 

366 if mode == _BLEND_SATURATION: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true

367 return _set_luminosity( 

368 _set_saturation(backdrop, _saturation(source)), 

369 _luminosity(backdrop), 

370 ) 

371 if mode == _BLEND_COLOR: 371 ↛ 373line 371 didn't jump to line 373 because the condition on line 371 was always true

372 return _set_luminosity(source, _luminosity(backdrop)) 

373 if mode == _BLEND_LUMINOSITY: 

374 return _set_luminosity(backdrop, _luminosity(source)) 

375 if mode == _BLEND_DIVIDE: 

376 return np.where( 

377 source > 0, 

378 np.minimum(1.0, _safe_divide(backdrop, source, 1.0)), 

379 np.where(backdrop > 0, 1.0, 0.0), 

380 ) 

381 # Normal, pass-through and unknown values fall back to plain "over". 

382 return source 

383 

384 

385def _composite( 

386 backdrop: np.ndarray, 

387 source: np.ndarray, 

388 mode: int, 

389 *, 

390 atop: bool = False, 

391) -> np.ndarray: 

392 """Blend premultiplied RGBA ``source`` onto ``backdrop``. 

393 

394 Uses the W3C compositing model: ``B(Cb, Cs)`` replaces the source colour 

395 where both layers overlap. ``atop`` keeps the backdrop's alpha, which is 

396 how a layer clipped to the layer below is drawn. 

397 """ 

398 

399 backdrop_alpha = backdrop[..., 3:4] 

400 source_alpha = source[..., 3:4] 

401 backdrop_color = backdrop[..., :3] 

402 source_color = source[..., :3] 

403 if mode == _BLEND_NORMAL or mode == _BLEND_PASS_THROUGH: 

404 mixed = source_color * backdrop_alpha 

405 else: 

406 straight_backdrop = _safe_divide(backdrop_color, backdrop_alpha, 0.0) 

407 straight_source = _safe_divide(source_color, source_alpha, 0.0) 

408 blended = np.nan_to_num( 

409 _blend_colors(mode, straight_backdrop, straight_source), 

410 nan=0.0, 

411 posinf=1.0, 

412 neginf=0.0, 

413 ) 

414 mixed = source_alpha * backdrop_alpha * np.clip(blended, 0.0, 1.0) 

415 if atop: 

416 color = mixed + backdrop_color * (1 - source_alpha) 

417 alpha = backdrop_alpha 

418 else: 

419 color = ( 

420 source_color * (1 - backdrop_alpha) 

421 + backdrop_color * (1 - source_alpha) 

422 + mixed 

423 ) 

424 alpha = source_alpha + backdrop_alpha * (1 - source_alpha) 

425 result = np.empty_like(backdrop) 

426 result[..., :3] = np.clip(color, 0.0, alpha) 

427 result[..., 3:4] = alpha 

428 return result 

429 

430 

431def _image_to_premultiplied(image: QImage) -> np.ndarray: 

432 rgba = qimage_rgba_array(image).astype(np.float32) / 255.0 

433 rgba[..., :3] *= rgba[..., 3:4] 

434 return rgba 

435 

436 

437def _premultiplied_to_image(pixels: np.ndarray) -> QImage: 

438 alpha = pixels[..., 3:4] 

439 straight = np.empty_like(pixels) 

440 straight[..., :3] = _safe_divide(pixels[..., :3], alpha, 0.0) 

441 straight[..., 3:4] = alpha 

442 return rgba_array_to_qimage( 

443 np.clip(np.rint(straight * 255.0), 0, 255).astype(np.uint8) 

444 ) 

445 

446 

447def _shift_pixels(pixels: np.ndarray, dx: int, dy: int) -> np.ndarray: 

448 if dx == 0 and dy == 0: 

449 return pixels 

450 height, width = pixels.shape[:2] 

451 result = np.zeros_like(pixels) 

452 if abs(dx) >= width or abs(dy) >= height: 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true

453 return result 

454 result[max(0, dy):height + min(0, dy), max(0, dx):width + min(0, dx)] = pixels[ 

455 max(0, -dy):height + min(0, -dy), max(0, -dx):width + min(0, -dx) 

456 ] 

457 return result 

458 

459 

460class _CelRenderer: 

461 """Flatten one CLIP cel (a leaf layer or a folder tree) into one image. 

462 

463 PMA keeps one flat image per cel, so folder structure, blend modes, 

464 clipping and layer masks are resolved here instead of being imported. 

465 """ 

466 

467 def __init__( 

468 self, 

469 reader, 

470 layers_by_id: dict[int, dict], 

471 width: int, 

472 height: int, 

473 ): 

474 self.reader = reader 

475 self.layers_by_id = layers_by_id 

476 self.width = width 

477 self.height = height 

478 

479 def render(self, cel: dict) -> QImage: 

480 # The cel itself is drawn on an empty canvas, so its own blend mode 

481 # and clipping flag have nothing to act on. Its visibility only means 

482 # "the current frame" inside CLIP and is ignored as well. 

483 if self._can_render_qt(cel, frozenset()): 

484 image = self._qt_content(cel, frozenset()) 

485 if self._opacity(cel) == 1.0: 

486 return image 

487 result = _blank_canvas(self.width, self.height) 

488 self._qt_draw(result, image, self._opacity(cel)) 

489 return result 

490 pixels = self._content(cel, frozenset()) * self._opacity(cel) 

491 return _premultiplied_to_image(pixels) 

492 

493 def _can_render_qt(self, layer: dict, stack: frozenset[int]) -> bool: 

494 """Normal blending and clipping can stay in Qt's premultiplied pixels.""" 

495 if (_row_number(layer, "LayerLayerMaskMipmap") 495 ↛ 497line 495 didn't jump to line 497 because the condition on line 495 was never true

496 and _row_number(layer, "LayerVisibility", 1) & 2): 

497 return False 

498 if self._is_folder(layer): 

499 children, child_stack = self._children(layer, stack) 

500 for child in children: 

501 if not self._visible(child): 

502 continue 

503 if (_row_number(child, "LayerComposite") != _BLEND_NORMAL 

504 or not self._can_render_qt(child, child_stack)): 

505 return False 

506 return True 

507 

508 @staticmethod 

509 def _qt_draw(target: QImage, source: QImage, opacity: float, *, atop=False, x=0, y=0): 

510 painter = QPainter(target) 

511 try: 

512 painter.setOpacity(opacity) 

513 if atop: 

514 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceAtop) 

515 painter.drawImage(QPoint(x, y), source) 

516 finally: 

517 painter.end() 

518 

519 def _qt_content(self, layer: dict, stack: frozenset[int]) -> QImage: 

520 if not self._is_folder(layer): 

521 return _render_cached_layer(self.reader, layer, self.width, self.height) 

522 children, child_stack = self._children(layer, stack) 

523 groups: list[tuple[dict, list[dict]]] = [] 

524 for child in reversed(children): 

525 if _row_number(child, "LayerClip") and groups: 

526 groups[-1][1].append(child) 

527 else: 

528 groups.append((child, [])) 

529 result = _blank_canvas(self.width, self.height) 

530 for base, clipped in groups: 

531 if not self._visible(base): 

532 continue 

533 group = self._qt_content(base, child_stack) 

534 for child in clipped: 

535 if self._visible(child): 535 ↛ 534line 535 didn't jump to line 534 because the condition on line 535 was always true

536 self._qt_draw(group, self._qt_content(child, child_stack), 

537 self._opacity(child), atop=True) 

538 self._qt_draw(result, group, self._opacity(base)) 

539 x, y = _row_number(layer, "LayerOffsetX"), _row_number(layer, "LayerOffsetY") 

540 if x or y: 

541 shifted = _blank_canvas(self.width, self.height) 

542 self._qt_draw(shifted, result, 1.0, x=x, y=y) 

543 result = shifted 

544 return _apply_layer_color(result, layer) 

545 

546 def _blank(self) -> np.ndarray: 

547 return np.zeros((self.height, self.width, 4), dtype=np.float32) 

548 

549 @staticmethod 

550 def _visible(layer: dict) -> bool: 

551 return bool(_row_number(layer, "LayerVisibility", 1) & 1) 

552 

553 @staticmethod 

554 def _opacity(layer: dict) -> float: 

555 return max(0.0, min(1.0, _row_number(layer, "LayerOpacity", 256) / 256.0)) 

556 

557 @staticmethod 

558 def _is_folder(layer: dict) -> bool: 

559 return bool( 

560 _row_number(layer, "LayerFirstChildIndex") 

561 or _row_number(layer, "LayerFolder") 

562 ) 

563 

564 def _children( 

565 self, layer: dict, stack: frozenset[int] 

566 ) -> tuple[list[dict], frozenset[int]]: 

567 layer_id = _row_number(layer, "MainId") 

568 if layer_id in stack: 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true

569 raise ClipImportError(tr("セル内のレイヤー構造が循環しています。")) 

570 return _child_layer_rows(self.layers_by_id, layer), stack | {layer_id} 

571 

572 def _content(self, layer: dict, stack: frozenset[int]) -> np.ndarray: 

573 """Pixels of one layer on its own, with its mask but before opacity.""" 

574 

575 if self._is_folder(layer): 

576 # CLIP STUDIO can omit a folder cache or leave one that represents 

577 # only part of the folder, so folders are always rebuilt from 

578 # their children. 

579 children, child_stack = self._children(layer, stack) 

580 pixels = _shift_pixels( 

581 self._composite_children(children, self._blank(), child_stack), 

582 _row_number(layer, "LayerOffsetX"), 

583 _row_number(layer, "LayerOffsetY"), 

584 ) 

585 else: 

586 pixels = _image_to_premultiplied( 

587 _render_cached_layer( 

588 self.reader, layer, self.width, self.height 

589 ) 

590 ) 

591 mask = self._mask(layer) 

592 if self._is_folder(layer) and _row_number(layer, "DrawColorEnable"): 592 ↛ 593line 592 didn't jump to line 593 because the condition on line 592 was never true

593 pixels = _image_to_premultiplied(_apply_layer_color(_premultiplied_to_image(pixels), layer)) 

594 if mask is not None: 

595 pixels *= mask[..., None] 

596 return pixels 

597 

598 def _composite_children( 

599 self, 

600 children: list[dict], 

601 backdrop: np.ndarray, 

602 stack: frozenset[int], 

603 ) -> np.ndarray: 

604 # Children are listed top to bottom. A clipped layer belongs to the 

605 # nearest unclipped layer below it; that base and its clipped layers 

606 # are combined first and then blended with the base's mode/opacity. 

607 groups: list[tuple[dict, list[dict]]] = [] 

608 for child in reversed(children): 

609 if _row_number(child, "LayerClip") and groups: 

610 groups[-1][1].append(child) 

611 else: 

612 groups.append((child, [])) 

613 result = backdrop 

614 for base, clipped in groups: 

615 if not self._visible(base): 

616 continue 

617 mode = _row_number(base, "LayerComposite") 

618 if mode == _BLEND_PASS_THROUGH and not clipped and self._is_folder(base): 

619 result = self._pass_through(base, result, stack) 

620 continue 

621 group = self._content(base, stack) 

622 for layer in clipped: 

623 if not self._visible(layer): 623 ↛ 624line 623 didn't jump to line 624 because the condition on line 623 was never true

624 continue 

625 group = _composite( 

626 group, 

627 self._content(layer, stack) * self._opacity(layer), 

628 _row_number(layer, "LayerComposite"), 

629 atop=True, 

630 ) 

631 result = _composite(result, group * self._opacity(base), mode) 

632 return result 

633 

634 def _pass_through( 

635 self, folder: dict, backdrop: np.ndarray, stack: frozenset[int] 

636 ) -> np.ndarray: 

637 """Draw a pass-through folder's children straight onto the backdrop.""" 

638 

639 children, child_stack = self._children(folder, stack) 

640 mixed = self._composite_children(children, backdrop.copy(), child_stack) 

641 weight = self._opacity(folder) 

642 mask = self._mask(folder) 

643 if mask is None and weight >= 1.0: 643 ↛ 645line 643 didn't jump to line 645 because the condition on line 643 was always true

644 return mixed 

645 amount = weight if mask is None else mask[..., None] * weight 

646 return backdrop + (mixed - backdrop) * amount 

647 

648 def _mask(self, layer: dict) -> Optional[np.ndarray]: 

649 """Return the layer mask on the canvas (1 = visible), if enabled.""" 

650 

651 mipmap_id = _row_number(layer, "LayerLayerMaskMipmap") 

652 if not mipmap_id or not (_row_number(layer, "LayerVisibility", 1) & 2): 

653 return None 

654 image, alpha_only, default_white = _native_raster(self.reader, mipmap_id) 

655 rgba = qimage_rgba_array(image) 

656 channel = 3 if alpha_only else 0 

657 values = rgba[..., channel].astype(np.float32) / 255.0 

658 mask = np.full( 

659 (self.height, self.width), 

660 1.0 if default_white else 0.0, 

661 dtype=np.float32, 

662 ) 

663 x = ( 

664 _row_number(layer, "LayerMaskOffsetX") 

665 + _row_number(layer, "LayerOffsetX") 

666 + _row_number(layer, "LayerMaskOffscrOffsetX") 

667 ) 

668 y = ( 

669 _row_number(layer, "LayerMaskOffsetY") 

670 + _row_number(layer, "LayerOffsetY") 

671 + _row_number(layer, "LayerMaskOffscrOffsetY") 

672 ) 

673 left, top = max(0, x), max(0, y) 

674 right = min(self.width, x + values.shape[1]) 

675 bottom = min(self.height, y + values.shape[0]) 

676 if left < right and top < bottom: 676 ↛ 680line 676 didn't jump to line 680 because the condition on line 676 was always true

677 mask[top:bottom, left:right] = values[ 

678 top - y:bottom - y, left - x:right - x 

679 ] 

680 return mask 

681 

682 

683def _numeric_cell_name(name: str) -> Optional[int]: 

684 stripped = str(name).strip() 

685 if not stripped or not stripped.isdecimal(): 

686 return None 

687 try: 

688 value = int(stripped) 

689 except ValueError: 

690 return None 

691 return value if 1 <= value <= MAX_PROJECT_FRAMES else None 

692 

693 

694def cell_sequence_numbers(layer: ClipAnimationLayer) -> dict[str, int]: 

695 """Return stable PMA sequence numbers for every CLIP cel in a folder.""" 

696 

697 result: dict[str, int] = {} 

698 used: set[int] = set() 

699 deferred: list[str] = [] 

700 for tag in layer.cell_images: 

701 number = _numeric_cell_name(tag) 

702 if number is None or number in used: 

703 deferred.append(tag) 

704 continue 

705 result[tag] = number 

706 used.add(number) 

707 next_number = 1 

708 for tag in deferred: 

709 while next_number in used: 

710 next_number += 1 

711 if next_number > MAX_PROJECT_FRAMES: 711 ↛ 712line 711 didn't jump to line 712 because the condition on line 711 was never true

712 raise ClipImportError(tr("アニメーションセルの絵番号がPMAの上限外です。")) 

713 result[tag] = next_number 

714 used.add(next_number) 

715 return result 

716 

717 

718def _layer_runs( 

719 keys: tuple[ClipCelKey, ...], 

720 start_frame: int, 

721 end_frame: int, 

722) -> list[tuple[int, int, str]]: 

723 duration = end_frame - start_frame + 1 

724 active = "" 

725 events: dict[int, str] = {} 

726 for key in keys: 

727 if key.frame <= start_frame: 

728 active = key.tag 

729 elif key.frame <= end_frame: 729 ↛ 726line 729 didn't jump to line 726 because the condition on line 729 was always true

730 events[key.frame - start_frame] = key.tag 

731 events[0] = active 

732 runs: list[tuple[int, int, str]] = [] 

733 current_start = 0 

734 current_tag = events[0] 

735 for position in sorted(value for value in events if value > 0): 

736 next_tag = events[position] 

737 if next_tag == current_tag: 

738 continue 

739 runs.append((current_start, position - current_start, current_tag)) 

740 current_start = position 

741 current_tag = next_tag 

742 runs.append((current_start, duration - current_start, current_tag)) 

743 return runs 

744 

745 

746def build_pma_frames( 

747 width: int, 

748 height: int, 

749 start_frame: int, 

750 end_frame: int, 

751 layers: tuple[ClipAnimationLayer, ...], 

752) -> tuple[Frame, ...]: 

753 """Convert parsed CLIP tracks into PMA keyframes plus exposure spans.""" 

754 

755 duration = end_frame - start_frame + 1 

756 if not (1 <= duration <= MAX_PROJECT_FRAMES): 756 ↛ 757line 756 didn't jump to line 757 because the condition on line 756 was never true

757 raise ClipImportError(tr("タイムラインのフレーム数がPMAの上限外です。")) 

758 if not (1 <= len(layers) <= MAX_PROJECT_LAYERS): 758 ↛ 759line 758 didn't jump to line 759 because the condition on line 758 was never true

759 raise ClipImportError(tr("アニメーションフォルダー数がPMAの上限外です。")) 

760 cell_count = duration * len(layers) 

761 if cell_count > MAX_PROJECT_LAYER_CELLS: 761 ↛ 762line 761 didn't jump to line 762 because the condition on line 761 was never true

762 raise ClipImportError(tr("タイムラインのレイヤーセル数がPMAの上限を超えています。")) 

763 # Held cells share their QImage pixels; only decoded cels and the shared 

764 # transparent canvas allocate image buffers, not every timeline slot. 

765 image_count = 1 + sum(len(layer.cell_images) for layer in layers) 

766 if width * height * image_count > MAX_PROJECT_DECODED_PIXELS: 

767 raise ClipImportError(tr("タイムライン展開後の画像サイズがPMAの上限を超えています。")) 

768 transparent = QImage(width, height, QImage.Format.Format_ARGB32_Premultiplied) 

769 transparent.fill(QColor(0, 0, 0, 0)) 

770 frames = [ 

771 Frame([ 

772 Layer(layer.name, QImage(transparent)) 

773 for layer in layers 

774 ]) 

775 for _ in range(duration) 

776 ] 

777 for layer_index, source_layer in enumerate(layers): 

778 sequence_numbers = cell_sequence_numbers(source_layer) 

779 for run_start, exposure, tag in _layer_runs( 

780 source_layer.keys, start_frame, end_frame 

781 ): 

782 target = frames[run_start].layers[layer_index] 

783 target.exposure = exposure 

784 if not tag: 

785 target.cell_name = None 

786 target.is_blank_key = True 

787 target.has_content = False 

788 continue 

789 image = source_layer.cell_images.get(tag) 

790 if image is None: 790 ↛ 791line 790 didn't jump to line 791 because the condition on line 790 was never true

791 raise ClipImportError( 

792 tr("アニメーションフォルダー「{name}」のセル「{tag}」を画像化できません。").format(name=source_layer.name, tag=tag) 

793 ) 

794 target.image = QImage(image) 

795 target.has_content = True 

796 target.is_blank_key = False 

797 target.sequence_number = sequence_numbers[tag] 

798 target.cell_name = ( 

799 None if _numeric_cell_name(tag) == target.sequence_number else tag 

800 ) 

801 return tuple(frames) 

802 

803 

804def _direct_children( 

805 layers_by_id: dict[int, dict], folder: dict 

806) -> dict[str, dict]: 

807 result: dict[str, dict] = {} 

808 for child in _child_layer_rows(layers_by_id, folder): 

809 name = str(child["LayerName"] or "") 

810 if name in result: 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true

811 raise ClipImportError( 

812 tr("アニメーションフォルダー内でセル名「{name}」が重複しています。").format(name=name) 

813 ) 

814 result[name] = child 

815 return result 

816 

817 

818def read_clip_animation( 

819 path: str | os.PathLike[str], 

820 *, 

821 progress: Callable[[int, int, str], None] | None = None, 

822) -> ClipAnimationDocument: 

823 """Read using clipfile-rs without modifying the source file.""" 

824 if Path(path).suffix.lower() != ".clip": 824 ↛ 825line 824 didn't jump to line 825 because the condition on line 824 was never true

825 raise ClipImportError(tr("CLIP STUDIOの.clipファイルを指定してください。")) 

826 def report(completed: int, total: int, label: str) -> None: 

827 if progress is not None: 

828 progress(completed, total, label) 

829 

830 try: 

831 report(0, 0, tr("CLIP STUDIOアニメーションを解析しています:{name}").format(name=Path(path).name)) 

832 reader = native.clip_animation_reader( 

833 str(path), MAX_IMAGE_DIMENSION, MAX_PROJECT_IMAGE_BYTES 

834 ) 

835 metadata = reader.metadata() 

836 width, height = (int(round(metadata[key])) for key in ("width", "height")) 

837 if not (1 <= width <= MAX_IMAGE_DIMENSION and 1 <= height <= MAX_IMAGE_DIMENSION): 837 ↛ 838line 837 didn't jump to line 838 because the condition on line 837 was never true

838 raise ClipImportError(tr(".clipのキャンバスサイズがPMAの上限外です。")) 

839 fps = metadata["fps"] 

840 start_frame = max(0, int(round(metadata["start_frame"]))) 

841 end_frame = max(0, int(round(metadata["end_frame"]))) 

842 layers_by_id = {row["MainId"]: row for row in metadata["layers"]} 

843 parents = {child: row["MainId"] for row in metadata["layers"] for child in row["children"]} 

844 

845 def folder_visible(layer_id: int) -> bool: 

846 seen = set() 

847 while layer_id in layers_by_id: 847 ↛ 856line 847 didn't jump to line 856 because the condition on line 847 was always true

848 if layer_id in seen: 848 ↛ 849line 848 didn't jump to line 849 because the condition on line 848 was never true

849 raise ClipImportError(tr("セル内のレイヤー構造が循環しています。")) 

850 seen.add(layer_id) 

851 if not _CelRenderer._visible(layers_by_id[layer_id]): 

852 return False 

853 if layer_id not in parents: 

854 break 

855 layer_id = parents[layer_id] 

856 return True 

857 

858 # PMA also stores layers bottom to top. Preserve tree stacking order, 

859 # which is independent of numeric Track/Layer IDs. 

860 stack_order: dict[int, int] = {} 

861 pending = list(reversed([row["MainId"] for row in metadata["layers"] if row["MainId"] not in parents])) 

862 while pending: 

863 layer_id = pending.pop() 

864 if layer_id in stack_order: 864 ↛ 865line 864 didn't jump to line 865 because the condition on line 864 was never true

865 raise ClipImportError(tr("セル内のレイヤー構造が循環しています。")) 

866 stack_order[layer_id] = len(stack_order) 

867 pending.extend(reversed(layers_by_id[layer_id]["children"])) 

868 renderer = _CelRenderer(reader, layers_by_id, width, height) 

869 parsed_layers: list[ClipAnimationLayer] = [] 

870 tracks = [ 

871 (layers_by_id[layer_id], curves) 

872 for layer_id, curves in metadata["tracks"] 

873 if ("AnimationFolder" not in layers_by_id[layer_id] 

874 or layers_by_id[layer_id]["AnimationFolder"]) 

875 and folder_visible(layer_id) 

876 ] 

877 tracks.sort(key=lambda item: stack_order.get(item[0]["MainId"], len(stack_order))) 

878 if not tracks: 

879 raise ClipImportError(tr("表示中のアニメーションフォルダーがありません。")) 

880 # Check allocation budgets before decoding cel images or building Qt frames. 

881 duration = end_frame - start_frame + 1 

882 if not 1 <= duration <= MAX_PROJECT_FRAMES: 

883 raise ClipImportError(tr("タイムラインのフレーム数がPMAの上限外です。")) 

884 if not 1 <= len(tracks) <= MAX_PROJECT_LAYERS: 884 ↛ 885line 884 didn't jump to line 885 because the condition on line 884 was never true

885 raise ClipImportError(tr("アニメーションフォルダー数がPMAの上限外です。")) 

886 cells = duration * len(tracks) 

887 if cells > MAX_PROJECT_LAYER_CELLS: 887 ↛ 888line 887 didn't jump to line 888 because the condition on line 887 was never true

888 raise ClipImportError(tr("タイムラインのレイヤーセル数がPMAの上限を超えています。")) 

889 if width * height > MAX_PROJECT_DECODED_PIXELS: 889 ↛ 890line 889 didn't jump to line 890 because the condition on line 889 was never true

890 raise ClipImportError(tr("タイムライン展開後の画像サイズがPMAの上限を超えています。")) 

891 folder_children = [_direct_children(layers_by_id, folder) for folder, _ in tracks] 

892 total_cels = sum(len(children) for children in folder_children) 

893 if (total_cels > MAX_PROJECT_LAYER_CELLS 893 ↛ 895line 893 didn't jump to line 895 because the condition on line 893 was never true

894 or width * height * (total_cels + 1) > MAX_PROJECT_DECODED_PIXELS): 

895 raise ClipImportError(tr("タイムライン展開後の画像サイズがPMAの上限を超えています。")) 

896 completed_cels = 0 

897 for (folder, candidates), children in zip(tracks, folder_children): 

898 matching = [ 

899 curve for curve in candidates 

900 if all(not tag or tag in children for _time, tag in curve) 

901 ] 

902 raw_keys = max(matching, key=len) if matching else (candidates[0] if candidates else []) 

903 # Negative keys are not carried forward to frame zero. Their 

904 # images remain available in the sequence-cell archive below. 

905 keys = tuple( 

906 ClipCelKey(frame, str(tag)) 

907 for time_60hz, tag in raw_keys 

908 for frame in (int(round(time_60hz * fps / 60.0)),) 

909 if frame >= 0 

910 ) 

911 required_tags = { 

912 key.tag 

913 for key in keys 

914 if key.tag and start_frame <= key.frame <= end_frame 

915 } 

916 missing_tags = required_tags.difference(children) 

917 if missing_tags: 917 ↛ 918line 917 didn't jump to line 918 because the condition on line 917 was never true

918 missing = sorted(missing_tags)[0] 

919 raise ClipImportError( 

920 tr("アニメーションフォルダー「{LayerName}」にセル「{missing}」がありません。").format(LayerName=folder['LayerName'], missing=missing) 

921 ) 

922 images: dict[str, QImage] = {} 

923 # Render every cel, including unused and negative-pre-roll-only 

924 # cels, so they survive as PMA sequence entries. 

925 for tag, child in children.items(): 

926 label = tr("セル画像を読み込み中:{folder} / {cel}").format(folder=folder["LayerName"], cel=tag) 

927 report(completed_cels, total_cels, label) 

928 images[tag] = renderer.render(child) 

929 ancestor_id = folder["MainId"] 

930 while ancestor_id in layers_by_id: 930 ↛ 935line 930 didn't jump to line 935 because the condition on line 930 was always true

931 images[tag] = _apply_layer_color(images[tag], layers_by_id[ancestor_id]) 

932 if ancestor_id not in parents: 

933 break 

934 ancestor_id = parents[ancestor_id] 

935 completed_cels += 1 

936 report(completed_cels, total_cels, label) 

937 parsed_layers.append( 

938 ClipAnimationLayer( 

939 str(folder["LayerName"] or f"Animation {len(parsed_layers) + 1}"), 

940 keys, 

941 images, 

942 ) 

943 ) 

944 if not parsed_layers: 944 ↛ 945line 944 didn't jump to line 945 because the condition on line 944 was never true

945 raise ClipImportError(tr(".clip内にアニメーションフォルダーがありません。")) 

946 layer_tuple = tuple(parsed_layers) 

947 report(total_cels, total_cels, tr("タイムラインを構築しています…")) 

948 frames = build_pma_frames(width, height, start_frame, end_frame, layer_tuple) 

949 return ClipAnimationDocument( 

950 Path(path).name, metadata["timeline_name"], width, height, fps, 

951 start_frame, end_frame, layer_tuple, frames, 

952 ) 

953 except (ValueError, OSError) as exc: 

954 if isinstance(exc, ClipImportError): 

955 raise 

956 raise ClipImportError(str(exc)) from exc 

957 

958 

959__all__ = [ 

960 "ClipAnimationDocument", "ClipAnimationLayer", "ClipCelKey", "ClipImportError", 

961 "build_pma_frames", "cell_sequence_numbers", "read_clip_animation", 

962]