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
« 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.
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
8import os
9from dataclasses import dataclass
10from pathlib import Path
11from typing import Callable, Optional
13import numpy as np
14from PySide6.QtCore import QPoint
15from PySide6.QtGui import QColor, QImage, QPainter
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
27class ClipImportError(ValueError):
28 """A safe, user-displayable failure while reading a .clip file."""
31@dataclass(frozen=True)
32class ClipCelKey:
33 frame: int
34 tag: str
37@dataclass(frozen=True)
38class ClipAnimationLayer:
39 name: str
40 keys: tuple[ClipCelKey, ...]
41 cell_images: dict[str, QImage]
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, ...]
56 @property
57 def folder_count(self) -> int:
58 return len(self.layers)
60 @property
61 def frame_count(self) -> int:
62 return len(self.frames)
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 }
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)
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)
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)
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
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)
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
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."""
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
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"])]
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
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)
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 )
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
257def _saturation(color: np.ndarray) -> np.ndarray:
258 return color.max(axis=-1, keepdims=True) - color.min(axis=-1, keepdims=True)
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)
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 )
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 )
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 )
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 )
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."""
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
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``.
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 """
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
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
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 )
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
460class _CelRenderer:
461 """Flatten one CLIP cel (a leaf layer or a folder tree) into one image.
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 """
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
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)
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
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()
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)
546 def _blank(self) -> np.ndarray:
547 return np.zeros((self.height, self.width, 4), dtype=np.float32)
549 @staticmethod
550 def _visible(layer: dict) -> bool:
551 return bool(_row_number(layer, "LayerVisibility", 1) & 1)
553 @staticmethod
554 def _opacity(layer: dict) -> float:
555 return max(0.0, min(1.0, _row_number(layer, "LayerOpacity", 256) / 256.0))
557 @staticmethod
558 def _is_folder(layer: dict) -> bool:
559 return bool(
560 _row_number(layer, "LayerFirstChildIndex")
561 or _row_number(layer, "LayerFolder")
562 )
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}
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."""
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
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
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."""
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
648 def _mask(self, layer: dict) -> Optional[np.ndarray]:
649 """Return the layer mask on the canvas (1 = visible), if enabled."""
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
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
694def cell_sequence_numbers(layer: ClipAnimationLayer) -> dict[str, int]:
695 """Return stable PMA sequence numbers for every CLIP cel in a folder."""
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
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
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."""
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)
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
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)
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"]}
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
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
959__all__ = [
960 "ClipAnimationDocument", "ClipAnimationLayer", "ClipCelKey", "ClipImportError",
961 "build_pma_frames", "cell_sequence_numbers", "read_clip_animation",
962]