csp-format 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csp_format/__init__.py +31 -0
- csp_format/animation.py +499 -0
- csp_format/assets/__init__.py +14 -0
- csp_format/assets/c2f.py +42 -0
- csp_format/assets/colorset.py +93 -0
- csp_format/assets/material.py +135 -0
- csp_format/assets/serial.py +323 -0
- csp_format/assets/shortcut.py +120 -0
- csp_format/assets/subtool.py +321 -0
- csp_format/assets/tar.py +66 -0
- csp_format/clip.py +478 -0
- csp_format/cmc.py +193 -0
- csp_format/container.py +79 -0
- csp_format/errors.py +5 -0
- csp_format/image.py +119 -0
- csp_format/offscreen.py +241 -0
- csp_format/text.py +145 -0
- csp_format/vector.py +139 -0
- csp_format/version.py +132 -0
- csp_format-0.1.0.dist-info/METADATA +84 -0
- csp_format-0.1.0.dist-info/RECORD +24 -0
- csp_format-0.1.0.dist-info/WHEEL +5 -0
- csp_format-0.1.0.dist-info/licenses/LICENSE +21 -0
- csp_format-0.1.0.dist-info/top_level.txt +1 -0
csp_format/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Read CLIP STUDIO PAINT files (.clip, .cmc) with the Python standard library only.
|
|
2
|
+
|
|
3
|
+
from csp_format import read_clip
|
|
4
|
+
with read_clip("page.clip") as clip:
|
|
5
|
+
for layer in clip.iter_layers():
|
|
6
|
+
found = clip.layer_pixels(layer) # (Image, (x, y)) or None
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
Images are plain RGBA (``Image.width``, ``height``, ``rgba``); with Pillow,
|
|
10
|
+
``PIL.Image.frombytes("RGBA", (image.width, image.height), bytes(image.rgba))``.
|
|
11
|
+
Animation (timelines, cels, keyframes, 2D camera): :mod:`csp_format.animation`.
|
|
12
|
+
The format is described in ``docs/spec``; the Rust crate in ``crates/``
|
|
13
|
+
also writes and renders files.
|
|
14
|
+
"""
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
from .animation import Animation, FrameState, Timeline, Track, read_animation
|
|
18
|
+
from .clip import Canvas, ClipFile, Layer, read_clip
|
|
19
|
+
from .cmc import CanvasNode, CmcFile, CmcProject, PageSlot, read_cmc
|
|
20
|
+
from .container import Container
|
|
21
|
+
from .errors import CspFormatError
|
|
22
|
+
from .image import Image, decode_png
|
|
23
|
+
from .text import Attributes, TextBlock
|
|
24
|
+
from .vector import Point, VectorObject, parse_vector_data
|
|
25
|
+
from .version import VersionInfo, read_version_info
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Animation", "Attributes", "Canvas", "CanvasNode", "ClipFile", "CmcFile", "CmcProject", "Container",
|
|
29
|
+
"CspFormatError", "FrameState", "Image", "Layer", "PageSlot", "Point", "TextBlock", "Timeline", "Track", "VectorObject",
|
|
30
|
+
"VersionInfo", "decode_png", "parse_vector_data", "read_animation", "read_clip", "read_cmc", "read_version_info",
|
|
31
|
+
]
|
csp_format/animation.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Animation: timelines, tracks, keys and their evaluation (R17; docs/spec/08-animation.md).
|
|
2
|
+
|
|
3
|
+
from csp_format import read_clip
|
|
4
|
+
from csp_format.animation import read_animation
|
|
5
|
+
with read_clip("anim.clip") as clip:
|
|
6
|
+
animation = read_animation(clip) # None without timelines
|
|
7
|
+
state = animation.frame_state(clip, animation.current, frame=0)
|
|
8
|
+
state.cels # {folder id: shown cel layer id or None}
|
|
9
|
+
state.transforms # {layer id: (affine, opacity 0-256 or None)}
|
|
10
|
+
|
|
11
|
+
The same rules as the Rust crate (``crates/csp-format/src/animation.rs``):
|
|
12
|
+
a frame ``f`` is at ``t = f * 60 / FrameRate`` ticks, each track's clip maps
|
|
13
|
+
``t`` to motion ticks ``m = MS + (t - TS) * (ME - MS) / (TE - TS)``, and keys
|
|
14
|
+
interpolate ``Constant`` (hold), ``Linear`` or ``Default`` (cubic Hermite
|
|
15
|
+
through the slopes, smoothstep when they are 0). Track keys are stored in
|
|
16
|
+
"cmt " documents (:func:`parse_document`), which :func:`document_bytes`
|
|
17
|
+
writes back byte for byte.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
import struct
|
|
23
|
+
import zlib
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
from .clip import _PER_INCH, UNITS
|
|
27
|
+
from .errors import CspFormatError
|
|
28
|
+
|
|
29
|
+
TYPES_0100 = ("null", "Byte", "SByte", "UInt16", "Int16", "UInt32", "Int32", "Single", "Double", "String", "Float2",
|
|
30
|
+
"Float3", "Quat", "Matrix44", "Single[]", "Byte[]", "Int32[]", "String[]", "Float2[]", "Float3[]",
|
|
31
|
+
"Quat[]", "Matrix44[]")
|
|
32
|
+
TYPES_0110 = tuple({"Float2": "Double2", "Float3": "Double3", "Single[]": "Double[]", "Float2[]": "Double2[]",
|
|
33
|
+
"Float3[]": "Double3[]"}.get(t, t) for t in TYPES_0100)
|
|
34
|
+
# struct format of each value type (arrays: of one element).
|
|
35
|
+
_FORMATS = {"Byte": "B", "SByte": "b", "UInt16": "H", "Int16": "h", "UInt32": "I", "Int32": "i", "Single": "f",
|
|
36
|
+
"Double": "d", "String": "I", "Float2": "2f", "Float3": "3f", "Double2": "2d", "Double3": "3d"}
|
|
37
|
+
TRACK_CELS, TRACK_KEYFRAMES, TRACK_PAPER, TRACK_CAMERA = 2000, 2001, 2003, 2005
|
|
38
|
+
LAYER_TYPE_LIGHT_TABLE = 10016
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Node:
|
|
43
|
+
"""A node of a "cmt " document. ``kind`` is the type name (``"Double[]"``, …)."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
kind: str = "null"
|
|
47
|
+
value: object = None
|
|
48
|
+
attributes: list[tuple[str, str]] = field(default_factory=list)
|
|
49
|
+
children: list["Node"] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
def attribute(self, name: str) -> str | None:
|
|
52
|
+
return next((v for k, v in self.attributes if k == name), None)
|
|
53
|
+
|
|
54
|
+
def child(self, name: str) -> "Node | None":
|
|
55
|
+
return next((c for c in self.children if c.name == name), None)
|
|
56
|
+
|
|
57
|
+
def find(self, *path: str) -> "Node | None":
|
|
58
|
+
node: Node | None = self
|
|
59
|
+
for name in path:
|
|
60
|
+
node = node.child(name) if node else None
|
|
61
|
+
return node
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _types(version: str) -> tuple[str, ...]:
|
|
65
|
+
return TYPES_0110 if version == "0110" else TYPES_0100
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_document(data: bytes) -> tuple[str, Node]:
|
|
69
|
+
"""``(version, root)`` of a "cmt " document (decompressed)."""
|
|
70
|
+
if data[:4] != b"cmt " or data[8:12] != b"binc":
|
|
71
|
+
raise CspFormatError("not a binary cmt document")
|
|
72
|
+
version = data[4:8].decode("ascii", "replace")
|
|
73
|
+
if version not in ("0100", "0110"):
|
|
74
|
+
raise CspFormatError(f"unknown cmt version {version!r}")
|
|
75
|
+
if struct.unpack_from("<I", data, 12)[0] != zlib.crc32(data[16:]) & 0xFFFFFFFF:
|
|
76
|
+
raise CspFormatError("cmt checksum does not match")
|
|
77
|
+
(count,), position = struct.unpack_from("<I", data, 16), 20
|
|
78
|
+
strings = []
|
|
79
|
+
for _ in range(count):
|
|
80
|
+
length = data[position]
|
|
81
|
+
strings.append(data[position + 1:position + 1 + length].decode("utf-8"))
|
|
82
|
+
position += 1 + length
|
|
83
|
+
types = _types(version)
|
|
84
|
+
if tuple(strings[:22]) != types:
|
|
85
|
+
raise CspFormatError("cmt string table does not start with the type names")
|
|
86
|
+
|
|
87
|
+
def read(fmt: str) -> tuple:
|
|
88
|
+
nonlocal position
|
|
89
|
+
values = struct.unpack_from("<" + fmt, data, position)
|
|
90
|
+
position += struct.calcsize("<" + fmt)
|
|
91
|
+
return values
|
|
92
|
+
|
|
93
|
+
def value(kind: str) -> object:
|
|
94
|
+
if kind == "null":
|
|
95
|
+
return None
|
|
96
|
+
base, array = (kind[:-2], True) if kind.endswith("[]") else (kind, False)
|
|
97
|
+
if base not in _FORMATS:
|
|
98
|
+
raise CspFormatError(f"cmt value type {kind} is not supported")
|
|
99
|
+
fmt = _FORMATS[base]
|
|
100
|
+
one = (lambda: strings[read(fmt)[0]]) if base == "String" else (
|
|
101
|
+
(lambda: read(fmt)[0]) if len(fmt) == 1 else (lambda: read(fmt)))
|
|
102
|
+
if not array:
|
|
103
|
+
return one()
|
|
104
|
+
(n,) = read("I")
|
|
105
|
+
if n * struct.calcsize("<" + fmt) > len(data) - position:
|
|
106
|
+
raise CspFormatError("cmt array does not fit")
|
|
107
|
+
return [one() for _ in range(n)]
|
|
108
|
+
|
|
109
|
+
def node(depth: int) -> Node:
|
|
110
|
+
if depth > 64:
|
|
111
|
+
raise CspFormatError("cmt tree too deep")
|
|
112
|
+
start = position
|
|
113
|
+
offsets = read("3I") if version == "0110" else None
|
|
114
|
+
name_index, type_index = read("2I")
|
|
115
|
+
kind = types[type_index]
|
|
116
|
+
result = Node(strings[name_index], kind, value(kind))
|
|
117
|
+
if offsets and position != start + offsets[1]:
|
|
118
|
+
raise CspFormatError(f"cmt node {result.name}: attribute offset")
|
|
119
|
+
for _ in range(read("I")[0]):
|
|
120
|
+
k, v = read("2I")
|
|
121
|
+
result.attributes.append((strings[k], strings[v]))
|
|
122
|
+
if offsets and position != start + offsets[2]:
|
|
123
|
+
raise CspFormatError(f"cmt node {result.name}: child offset")
|
|
124
|
+
result.children = [node(depth + 1) for _ in range(read("I")[0])]
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
root = node(0)
|
|
128
|
+
if position != len(data):
|
|
129
|
+
raise CspFormatError("bytes after the cmt root node")
|
|
130
|
+
return version, root
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def document_bytes(version: str, root: Node) -> bytes:
|
|
134
|
+
"""Write a "cmt " document (string table in CSP's first-use order)."""
|
|
135
|
+
types = _types(version)
|
|
136
|
+
strings = list(types)
|
|
137
|
+
index: dict[str, int] = {}
|
|
138
|
+
|
|
139
|
+
def add(text: str) -> None:
|
|
140
|
+
if text not in index and text not in strings[:22]:
|
|
141
|
+
index[text] = len(strings)
|
|
142
|
+
strings.append(text)
|
|
143
|
+
|
|
144
|
+
def collect(node: Node) -> None:
|
|
145
|
+
add(node.name)
|
|
146
|
+
if node.kind == "String":
|
|
147
|
+
add(node.value) # type: ignore[arg-type]
|
|
148
|
+
elif node.kind == "String[]":
|
|
149
|
+
for text in node.value: # type: ignore[union-attr]
|
|
150
|
+
add(text)
|
|
151
|
+
for k, v in node.attributes:
|
|
152
|
+
add(k)
|
|
153
|
+
add(v)
|
|
154
|
+
for child in node.children:
|
|
155
|
+
collect(child)
|
|
156
|
+
|
|
157
|
+
collect(root)
|
|
158
|
+
position_of = {text: i for i, text in reversed(list(enumerate(strings)))}
|
|
159
|
+
|
|
160
|
+
def pack_value(node: Node) -> bytes:
|
|
161
|
+
if node.kind == "null":
|
|
162
|
+
return b""
|
|
163
|
+
base, array = (node.kind[:-2], True) if node.kind.endswith("[]") else (node.kind, False)
|
|
164
|
+
fmt = "<" + _FORMATS[base]
|
|
165
|
+
|
|
166
|
+
def one(v: object) -> bytes:
|
|
167
|
+
if base == "String":
|
|
168
|
+
return struct.pack(fmt, position_of[v]) # type: ignore[index]
|
|
169
|
+
return struct.pack(fmt, *(v if isinstance(v, (tuple, list)) else (v,)))
|
|
170
|
+
|
|
171
|
+
if not array:
|
|
172
|
+
return one(node.value)
|
|
173
|
+
return struct.pack("<I", len(node.value)) + b"".join(one(v) for v in node.value) # type: ignore
|
|
174
|
+
|
|
175
|
+
def pack_node(node: Node) -> bytes:
|
|
176
|
+
body = struct.pack("<2I", position_of[node.name], types.index(node.kind)) + pack_value(node)
|
|
177
|
+
attributes = struct.pack("<I", len(node.attributes)) + b"".join(
|
|
178
|
+
struct.pack("<2I", position_of[k], position_of[v]) for k, v in node.attributes)
|
|
179
|
+
children = struct.pack("<I", len(node.children)) + b"".join(pack_node(c) for c in node.children)
|
|
180
|
+
if version == "0110":
|
|
181
|
+
header = struct.pack("<3I", 16, 12 + len(body), 12 + len(body) + len(attributes))
|
|
182
|
+
return header + body + attributes + children
|
|
183
|
+
return body + attributes + children
|
|
184
|
+
|
|
185
|
+
table = struct.pack("<I", len(strings)) + b"".join(
|
|
186
|
+
bytes([len(s.encode("utf-8"))]) + s.encode("utf-8") for s in strings)
|
|
187
|
+
rest = table + pack_node(root)
|
|
188
|
+
return b"cmt " + version.encode("ascii") + b"binc" + struct.pack("<I", zlib.crc32(rest) & 0xFFFFFFFF) + rest
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def unpack_chunk(payload: bytes) -> bytes:
|
|
192
|
+
"""A mixer's external chunk payload: u64 BE rest, u32 LE zlib length, zlib."""
|
|
193
|
+
if len(payload) < 12 or struct.unpack_from(">Q", payload)[0] != len(payload) - 8:
|
|
194
|
+
raise CspFormatError("cmt chunk lengths do not match")
|
|
195
|
+
return zlib.decompress(payload[12:])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# -- keys -------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class Key:
|
|
203
|
+
frame: float
|
|
204
|
+
value: float
|
|
205
|
+
left_slope: float = 0.0
|
|
206
|
+
right_slope: float = 0.0
|
|
207
|
+
interp: str = "Default"
|
|
208
|
+
tag: str | None = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class Curve:
|
|
213
|
+
kind: str
|
|
214
|
+
axis: str | None
|
|
215
|
+
keys: list[Key]
|
|
216
|
+
|
|
217
|
+
def evaluate(self, m: float) -> tuple[float, int | None] | None:
|
|
218
|
+
"""``(value, index of the key in effect or None before the first)``."""
|
|
219
|
+
if not self.keys:
|
|
220
|
+
return None
|
|
221
|
+
if m < self.keys[0].frame:
|
|
222
|
+
return self.keys[0].value, None
|
|
223
|
+
i = max(j for j, key in enumerate(self.keys) if key.frame <= m)
|
|
224
|
+
key = self.keys[i]
|
|
225
|
+
if i + 1 == len(self.keys):
|
|
226
|
+
return key.value, i
|
|
227
|
+
nxt = self.keys[i + 1]
|
|
228
|
+
span = nxt.frame - key.frame
|
|
229
|
+
if span <= 0 or key.interp == "Constant":
|
|
230
|
+
return key.value, i
|
|
231
|
+
u = (m - key.frame) / span
|
|
232
|
+
if key.interp == "Default":
|
|
233
|
+
h00, h10 = 2 * u ** 3 - 3 * u ** 2 + 1, u ** 3 - 2 * u ** 2 + u
|
|
234
|
+
h01, h11 = -2 * u ** 3 + 3 * u ** 2, u ** 3 - u ** 2
|
|
235
|
+
return (h00 * key.value + h10 * span * key.right_slope + h01 * nxt.value
|
|
236
|
+
+ h11 * span * nxt.left_slope), i
|
|
237
|
+
return key.value + (nxt.value - key.value) * u, i
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class Clip:
|
|
242
|
+
time: tuple[float, float]
|
|
243
|
+
motion: tuple[float, float]
|
|
244
|
+
params: dict[str, object]
|
|
245
|
+
curves: list[Curve]
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def from_root(cls, root: Node) -> "Clip":
|
|
249
|
+
clip = root.find("General", "ActionNodeClip")
|
|
250
|
+
if clip is None:
|
|
251
|
+
raise CspFormatError("track mixer has no ActionNodeClip")
|
|
252
|
+
|
|
253
|
+
def number(*path: str) -> float:
|
|
254
|
+
node = clip.find(*path)
|
|
255
|
+
return float(node.value) if node is not None and isinstance(node.value, (int, float)) else 0.0
|
|
256
|
+
|
|
257
|
+
params: dict[str, object] = {}
|
|
258
|
+
curves = []
|
|
259
|
+
info = clip.child("AnimInfo")
|
|
260
|
+
for node in info.children if info else []:
|
|
261
|
+
if node.name == "Param":
|
|
262
|
+
params[node.attribute("Name") or ""] = node.value
|
|
263
|
+
elif node.name == "FCurve":
|
|
264
|
+
get = {c.name: c.value for c in node.children}
|
|
265
|
+
if not isinstance(get.get("Frame"), list) or not isinstance(get.get("Value"), list) \
|
|
266
|
+
or len(get["Frame"]) != len(get["Value"]):
|
|
267
|
+
raise CspFormatError(f"FCurve {node.attribute('Type')}: Frame and Value do not match")
|
|
268
|
+
n = len(get["Frame"])
|
|
269
|
+
|
|
270
|
+
def pick(name: str, i: int, default):
|
|
271
|
+
values = get.get(name)
|
|
272
|
+
return values[i] if isinstance(values, list) and i < len(values) else default
|
|
273
|
+
|
|
274
|
+
curves.append(Curve(node.attribute("Type") or "", node.attribute("Axis"), [
|
|
275
|
+
Key(get["Frame"][i], get["Value"][i], pick("LeftSlope", i, 0.0), pick("RightSlope", i, 0.0),
|
|
276
|
+
pick("Interp", i, "Default"), pick("Tag", i, None)) for i in range(n)]))
|
|
277
|
+
return cls((number("TimeClip", "Start"), number("TimeClip", "End")),
|
|
278
|
+
(number("MotionClip", "Start"), number("MotionClip", "End")), params, curves)
|
|
279
|
+
|
|
280
|
+
def motion_at(self, t: float) -> float:
|
|
281
|
+
(ts, te), (ms, me) = self.time, self.motion
|
|
282
|
+
return ms if te == ts else ms + (t - ts) * (me - ms) / (te - ts)
|
|
283
|
+
|
|
284
|
+
def curve(self, kind: str, axis: str | None = None) -> Curve | None:
|
|
285
|
+
return next((c for c in self.curves if c.kind == kind and c.axis == axis), None)
|
|
286
|
+
|
|
287
|
+
def value(self, kind: str, t: float, axis: str | None = None) -> float | None:
|
|
288
|
+
curve = self.curve(kind, axis)
|
|
289
|
+
found = curve.evaluate(self.motion_at(t)) if curve else None
|
|
290
|
+
if found is not None:
|
|
291
|
+
return found[0]
|
|
292
|
+
param = self.params.get(kind)
|
|
293
|
+
if isinstance(param, (tuple, list)):
|
|
294
|
+
return float(param[0 if axis == "X" else 1])
|
|
295
|
+
return float(param) if isinstance(param, (int, float)) else None
|
|
296
|
+
|
|
297
|
+
def pair(self, kind: str, t: float, default: tuple[float, float]) -> tuple[float, float]:
|
|
298
|
+
x, y = self.value(kind, t, "X"), self.value(kind, t, "Y")
|
|
299
|
+
return (x, y) if x is not None and y is not None else default
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# -- model ------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass
|
|
306
|
+
class Track:
|
|
307
|
+
main_id: int
|
|
308
|
+
timeline: int
|
|
309
|
+
kind: int
|
|
310
|
+
layer_uuid: str
|
|
311
|
+
layer_id: int | None
|
|
312
|
+
clip: Clip | None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class Label:
|
|
317
|
+
main_id: int
|
|
318
|
+
name: str
|
|
319
|
+
frame: float
|
|
320
|
+
kind: int
|
|
321
|
+
length: float | None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass
|
|
325
|
+
class Timeline:
|
|
326
|
+
main_id: int
|
|
327
|
+
name: str
|
|
328
|
+
frame_rate: float
|
|
329
|
+
start_frame: float
|
|
330
|
+
end_frame: float
|
|
331
|
+
current_frame: float
|
|
332
|
+
labels: list[Label]
|
|
333
|
+
tracks: list[Track]
|
|
334
|
+
|
|
335
|
+
def ticks(self, frame: float) -> float:
|
|
336
|
+
return frame * 60.0 / max(self.frame_rate, 1e-9)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
Affine = tuple[float, float, float, float, float, float]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def keyframe_affine(center, position, scale, rotation) -> Affine:
|
|
343
|
+
"""``p -> position + R(rotation) * diag(scale) * (p - center)``, rotation in degrees, clockwise."""
|
|
344
|
+
s, c = math.sin(math.radians(rotation)), math.cos(math.radians(rotation))
|
|
345
|
+
a, b, d, e = c * scale[0], -s * scale[1], s * scale[0], c * scale[1]
|
|
346
|
+
return a, b, position[0] - a * center[0] - b * center[1], d, e, position[1] - d * center[0] - e * center[1]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@dataclass
|
|
350
|
+
class FrameState:
|
|
351
|
+
hidden: set[int] = field(default_factory=set)
|
|
352
|
+
#: layer id -> (original canvas position -> canvas position, opacity 0-256 or None)
|
|
353
|
+
transforms: dict[int, tuple[Affine, int | None]] = field(default_factory=dict)
|
|
354
|
+
#: camera folder id -> (output canvas position -> canvas position shown there)
|
|
355
|
+
cameras: dict[int, Affine] = field(default_factory=dict)
|
|
356
|
+
cels: dict[int, int | None] = field(default_factory=dict)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@dataclass
|
|
360
|
+
class Animation:
|
|
361
|
+
timelines: list[Timeline]
|
|
362
|
+
current: int
|
|
363
|
+
#: reference frame (x, y, width, height) in canvas pixels
|
|
364
|
+
frame: tuple[float, float, float, float]
|
|
365
|
+
|
|
366
|
+
def frame_state(self, clip, index: int, frame: float) -> FrameState:
|
|
367
|
+
timeline = self.timelines[index]
|
|
368
|
+
t = timeline.ticks(frame)
|
|
369
|
+
tracks = {track.layer_id: track for track in timeline.tracks if track.layer_id is not None}
|
|
370
|
+
state = FrameState()
|
|
371
|
+
fx, fy, fw, fh = self.frame
|
|
372
|
+
for layer, _ in clip.root.walk():
|
|
373
|
+
if layer.layer_type == LAYER_TYPE_LIGHT_TABLE:
|
|
374
|
+
state.hidden.add(layer.main_id)
|
|
375
|
+
continue
|
|
376
|
+
row = clip.layer_row(layer.main_id)
|
|
377
|
+
track = tracks.get(layer.main_id)
|
|
378
|
+
is_folder = "AnimationFolder" in row.keys() and row["AnimationFolder"]
|
|
379
|
+
if track is None or track.clip is None:
|
|
380
|
+
if track is None and is_folder:
|
|
381
|
+
state.hidden.update(child.main_id for child in layer.children)
|
|
382
|
+
state.cels[layer.main_id] = None
|
|
383
|
+
continue
|
|
384
|
+
c = track.clip
|
|
385
|
+
if track.kind == TRACK_CELS:
|
|
386
|
+
curve = c.curve("ImageCelName")
|
|
387
|
+
found = curve.evaluate(c.motion_at(t)) if curve else None
|
|
388
|
+
name = curve.keys[found[1]].tag if found and found[1] is not None else None
|
|
389
|
+
shown = next((ch.main_id for ch in reversed(layer.children) if ch.name == name), None)
|
|
390
|
+
state.hidden.update(ch.main_id for ch in layer.children if ch.main_id != shown)
|
|
391
|
+
state.cels[layer.main_id] = shown
|
|
392
|
+
elif track.kind == TRACK_KEYFRAMES:
|
|
393
|
+
if "TimeLineLayerKeyFrameEnabled" not in row.keys() or not row["TimeLineLayerKeyFrameEnabled"]:
|
|
394
|
+
continue
|
|
395
|
+
if t < c.time[0] or t >= c.time[1]:
|
|
396
|
+
state.hidden.add(layer.main_id)
|
|
397
|
+
continue
|
|
398
|
+
center = c.pair("ImageCenter", t, (0.0, 0.0))
|
|
399
|
+
scale = c.pair("ImageAspectScale", t, (100.0, 100.0))
|
|
400
|
+
affine = keyframe_affine(center, c.pair("ImagePosition", t, center),
|
|
401
|
+
(scale[0] / 100, scale[1] / 100), c.value("ImageRotation", t) or 0.0)
|
|
402
|
+
opacity = c.value("Opacity", t)
|
|
403
|
+
state.transforms[layer.main_id] = (affine, None if opacity is None else _opacity(opacity))
|
|
404
|
+
elif track.kind == TRACK_CAMERA:
|
|
405
|
+
center = c.pair("ImageCenter", t, (fw / 2, fh / 2))
|
|
406
|
+
position = c.pair("ImagePosition", t, (fx + center[0], fy + center[1]))
|
|
407
|
+
scale = (c.value("ImageScale", t) or 100.0) / 100
|
|
408
|
+
a, b, cc, d, e, f = keyframe_affine(center, position, (scale, scale), c.value("ImageRotation", t) or 0.0)
|
|
409
|
+
state.cameras[layer.main_id] = (a, b, cc - a * fx - b * fy, d, e, f - d * fx - e * fy)
|
|
410
|
+
opacity = c.value("Opacity", t)
|
|
411
|
+
if opacity is not None:
|
|
412
|
+
state.transforms[layer.main_id] = ((1.0, 0.0, 0.0, 0.0, 1.0, 0.0), _opacity(opacity))
|
|
413
|
+
return state
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _opacity(percent: float) -> int:
|
|
417
|
+
"""CSP applies a keyed opacity as ``round(v * 256 / 100) / 256``."""
|
|
418
|
+
return max(0, min(256, math.floor(percent * 256 / 100 + 0.5)))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _uuid(value) -> str:
|
|
422
|
+
if isinstance(value, (bytes, bytearray)):
|
|
423
|
+
return bytes(value).hex()
|
|
424
|
+
return str(value or "").replace("-", "").lower()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _frame(clip) -> tuple[float, float, float, float]:
|
|
428
|
+
"""The reference frame: the basic frame (基本枠) centred in the trim frame (仕上がり), in canvas pixels."""
|
|
429
|
+
row = clip.database.execute("SELECT * FROM Canvas LIMIT 1").fetchone()
|
|
430
|
+
per_inch = _PER_INCH.get(UNITS.get(row["CropFrameUnitKind"] if "CropFrameUnitKind" in row.keys() else 0, "px"))
|
|
431
|
+
|
|
432
|
+
def px(column: str) -> float:
|
|
433
|
+
value = float(row[column] or 0.0) if column in row.keys() else 0.0
|
|
434
|
+
return value if per_inch is None else value * clip.canvas.resolution / per_inch
|
|
435
|
+
|
|
436
|
+
cw, ch = float(clip.canvas.width_px), float(clip.canvas.height_px)
|
|
437
|
+
crop = (px("CropFrameWidth"), px("CropFrameHeight"))
|
|
438
|
+
if crop[0] <= 0 or crop[1] <= 0:
|
|
439
|
+
return 0.0, 0.0, cw, ch
|
|
440
|
+
inner = (px("CropFrameInnerWidth"), px("CropFrameInnerHeight"))
|
|
441
|
+
w, h = inner if inner[0] > 0 and inner[1] > 0 else crop
|
|
442
|
+
x = (cw - crop[0]) / 2 + px("CropFrameCropOffsetX") + (crop[0] - w) / 2 + px("CropFrameInnerOffsetX")
|
|
443
|
+
y = (ch - crop[1]) / 2 + px("CropFrameCropOffsetY") + (crop[1] - h) / 2 + px("CropFrameInnerOffsetY")
|
|
444
|
+
return x, y, w, h
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def read_animation(clip) -> Animation | None:
|
|
448
|
+
"""The animation of an opened :class:`~csp_format.ClipFile`, None without timelines."""
|
|
449
|
+
if not clip.has_table("TimeLine"):
|
|
450
|
+
return None
|
|
451
|
+
db = clip.database
|
|
452
|
+
timelines_by_id = {row["MainId"]: row for row in db.execute("SELECT * FROM TimeLine")}
|
|
453
|
+
if not timelines_by_id:
|
|
454
|
+
return None
|
|
455
|
+
tracks_by_id = {row["MainId"]: row for row in db.execute("SELECT * FROM Track")} if clip.has_table("Track") else {}
|
|
456
|
+
labels_by_id = ({row["MainId"]: row for row in db.execute("SELECT * FROM TimeLineLabel")}
|
|
457
|
+
if clip.has_table("TimeLineLabel") else {})
|
|
458
|
+
bank = db.execute("SELECT * FROM AnimationCutBank LIMIT 1").fetchone() if clip.has_table("AnimationCutBank") else None
|
|
459
|
+
layers = {}
|
|
460
|
+
for layer, _ in clip.root.walk():
|
|
461
|
+
uuid = clip.layer_row(layer.main_id)["LayerUuid"]
|
|
462
|
+
if uuid:
|
|
463
|
+
layers[_uuid(uuid)] = layer.main_id
|
|
464
|
+
|
|
465
|
+
def chain(first: int, rows: dict, next_column: str) -> list:
|
|
466
|
+
out, seen = [], set()
|
|
467
|
+
while first:
|
|
468
|
+
if first in seen or first not in rows:
|
|
469
|
+
raise CspFormatError(f"broken {next_column} list at {first}")
|
|
470
|
+
seen.add(first)
|
|
471
|
+
out.append(rows[first])
|
|
472
|
+
first = rows[first][next_column] or 0
|
|
473
|
+
return out
|
|
474
|
+
|
|
475
|
+
def labels(first: int) -> list[Label]:
|
|
476
|
+
return [Label(r["MainId"], r["LabelName"] or "", r["LabelFrame"] or 0.0, r["LabelType"] or 0,
|
|
477
|
+
r["LabelLength"] if "LabelLength" in r.keys() else None)
|
|
478
|
+
for r in chain(first or 0, labels_by_id, "LabelNextIndex")]
|
|
479
|
+
|
|
480
|
+
first = bank["FirstTimeLine"] if bank is not None and bank["FirstTimeLine"] else min(
|
|
481
|
+
set(timelines_by_id) - {r["NextTimeLine"] for r in timelines_by_id.values()}, default=0)
|
|
482
|
+
timelines = []
|
|
483
|
+
for row in chain(first, timelines_by_id, "NextTimeLine"):
|
|
484
|
+
tracks = []
|
|
485
|
+
for track in chain(row["FirstTrack"] or 0, tracks_by_id, "TrackNextIndex"):
|
|
486
|
+
mixer = None
|
|
487
|
+
for column in ("TrackActionMixer2", "TrackActionMixer"):
|
|
488
|
+
ident = track[column]
|
|
489
|
+
payload = clip.container.external(ident.decode() if isinstance(ident, bytes) else ident) if ident else None
|
|
490
|
+
if payload is not None:
|
|
491
|
+
mixer = Clip.from_root(parse_document(unpack_chunk(payload))[1])
|
|
492
|
+
break
|
|
493
|
+
uuid = _uuid(track["LayerUuidWithTrack"])
|
|
494
|
+
tracks.append(Track(track["MainId"], track["ItemId"], track["TrackKind"], uuid, layers.get(uuid), mixer))
|
|
495
|
+
timelines.append(Timeline(row["MainId"], row["TimeLineName"] or "", row["FrameRate"] or 0.0,
|
|
496
|
+
row["StartFrame"] or 0.0, row["EndFrame"] or 0.0, row["CurrentFrame"] or 0.0,
|
|
497
|
+
labels(row["LabelFirstIndex"]), tracks))
|
|
498
|
+
current = min(max(int(bank["CurrentIndex"] or 0) if bank is not None else 0, 0), len(timelines) - 1)
|
|
499
|
+
return Animation(timelines, current, _frame(clip))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""CSP's files other than canvases, with the standard library only (A14).
|
|
2
|
+
|
|
3
|
+
from csp_format.assets import subtool, serial, colorset
|
|
4
|
+
db = subtool.SubToolDb.open("EditImageTool.todb")
|
|
5
|
+
for index, depth, path in db.sub_tools():
|
|
6
|
+
node = db.nodes[index]
|
|
7
|
+
print("/".join(path + [node.name]), db.settings(node.variant_id)["BrushSize"])
|
|
8
|
+
|
|
9
|
+
Mirrors ``crates/csp-assets`` (reading; colour sets, serialised documents
|
|
10
|
+
and tars are also written). Encrypted material data is never decrypted.
|
|
11
|
+
"""
|
|
12
|
+
from . import c2f, colorset, material, serial, shortcut, subtool, tar
|
|
13
|
+
|
|
14
|
+
__all__ = ["c2f", "colorset", "material", "serial", "shortcut", "subtool", "tar"]
|
csp_format/assets/c2f.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The chunk envelope of CSP's encrypted material data (``\\x89C2F``).
|
|
2
|
+
|
|
3
|
+
Only the envelope is read; the payload is encrypted by CELSYS and is not
|
|
4
|
+
decrypted (``docs/assets/requirements.md``, C5).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import struct
|
|
9
|
+
import zlib
|
|
10
|
+
|
|
11
|
+
from ..errors import CspFormatError
|
|
12
|
+
|
|
13
|
+
SIGNATURE = b"\x89C2F\r\n\x1a\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def chunks(data: bytes) -> list[tuple[str, bytes, bool]]:
|
|
17
|
+
"""(type, data, CRC matches) per chunk."""
|
|
18
|
+
if not data.startswith(SIGNATURE):
|
|
19
|
+
raise CspFormatError("not a C2F file")
|
|
20
|
+
out, at = [], len(SIGNATURE)
|
|
21
|
+
while at < len(data):
|
|
22
|
+
if at + 8 > len(data):
|
|
23
|
+
raise CspFormatError(f"C2F: chunk header cut at {at:#x}")
|
|
24
|
+
length, kind = struct.unpack_from("<I4s", data, at)
|
|
25
|
+
end = at + 8 + length
|
|
26
|
+
if end + 4 > len(data):
|
|
27
|
+
raise CspFormatError(f"C2F: chunk at {at:#x} runs past the end")
|
|
28
|
+
body = data[at + 8:end]
|
|
29
|
+
(stored,) = struct.unpack_from("<I", data, end)
|
|
30
|
+
out.append((kind.decode("latin-1"), body, stored == zlib.crc32(kind + body) & 0xFFFFFFFF))
|
|
31
|
+
at = end + 4
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def summary(data: bytes) -> dict:
|
|
36
|
+
found = chunks(data)
|
|
37
|
+
return {
|
|
38
|
+
"chunks": [kind for kind, _, _ in found],
|
|
39
|
+
"payload_bytes": sum(len(body) for kind, body, _ in found if kind == "dATA"),
|
|
40
|
+
"crc_ok": all(ok for _, _, ok in found),
|
|
41
|
+
"encrypted": True,
|
|
42
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Colour sets (.cls, SLCC), little endian; see ``crates/csp-assets/src/colorset.rs``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import struct
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from ..errors import CspFormatError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Color:
|
|
12
|
+
rgba: tuple[int, int, int, int]
|
|
13
|
+
name: str | None = None
|
|
14
|
+
flag: int = 0
|
|
15
|
+
extra: bytes = b""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ColorSet:
|
|
20
|
+
name: str
|
|
21
|
+
colors: list[Color] = field(default_factory=list)
|
|
22
|
+
version: int = 0x0100
|
|
23
|
+
format: int = 4
|
|
24
|
+
sjis_name: bytes | None = None
|
|
25
|
+
after_sjis: int = 0
|
|
26
|
+
|
|
27
|
+
def to_json(self) -> dict:
|
|
28
|
+
"""The JSON of ``cspf asset colorset``."""
|
|
29
|
+
return {
|
|
30
|
+
"name": self.name,
|
|
31
|
+
"version": self.version,
|
|
32
|
+
"format": self.format,
|
|
33
|
+
"colors": [{"rgba": list(c.rgba), "name": c.name} for c in self.colors],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse(data: bytes) -> ColorSet:
|
|
38
|
+
if not data.startswith(b"SLCC"):
|
|
39
|
+
raise CspFormatError("not a colour set (no SLCC signature)")
|
|
40
|
+
try:
|
|
41
|
+
(version, block) = struct.unpack_from("<HI", data, 4)
|
|
42
|
+
at = 10
|
|
43
|
+
block_end = at + block
|
|
44
|
+
(n,) = struct.unpack_from("<H", data, at)
|
|
45
|
+
sjis = data[at + 2:at + 2 + n]
|
|
46
|
+
at += 2 + n
|
|
47
|
+
(after_sjis, n) = struct.unpack_from("<IH", data, at)
|
|
48
|
+
name = data[at + 6:at + 6 + n].decode("utf-8")
|
|
49
|
+
at += 6 + n
|
|
50
|
+
if at != block_end:
|
|
51
|
+
raise CspFormatError(f"colour set: name block ends at {at:#x}, not {block_end:#x}")
|
|
52
|
+
(fmt, count, size) = struct.unpack_from("<III", data, at)
|
|
53
|
+
at += 12
|
|
54
|
+
if at + size != len(data):
|
|
55
|
+
raise CspFormatError(f"colour set: list of {size} bytes, file has {len(data) - at}")
|
|
56
|
+
colors = []
|
|
57
|
+
for _ in range(count):
|
|
58
|
+
(n,) = struct.unpack_from("<I", data, at)
|
|
59
|
+
entry = data[at + 4:at + 4 + n]
|
|
60
|
+
if n < 8 or len(entry) != n:
|
|
61
|
+
raise CspFormatError(f"colour set: {n}-byte colour")
|
|
62
|
+
at += 4 + n
|
|
63
|
+
(flag,) = struct.unpack_from("<I", entry, 4)
|
|
64
|
+
color_name, end = None, 8
|
|
65
|
+
if flag == 1 and n >= 10:
|
|
66
|
+
(length,) = struct.unpack_from("<H", entry, 8)
|
|
67
|
+
if 10 + length <= n:
|
|
68
|
+
color_name = entry[10:10 + length].decode("utf-16-le", "replace")
|
|
69
|
+
end = 10 + length
|
|
70
|
+
colors.append(Color(tuple(entry[:4]), color_name, flag, entry[end:]))
|
|
71
|
+
except (struct.error, UnicodeDecodeError) as error:
|
|
72
|
+
raise CspFormatError(f"colour set: {error}") from error
|
|
73
|
+
if at != len(data):
|
|
74
|
+
raise CspFormatError("colour set: colour list length does not match")
|
|
75
|
+
return ColorSet(name, colors, version, fmt, sjis, after_sjis)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def to_bytes(colors: ColorSet) -> bytes:
|
|
79
|
+
sjis = colors.sjis_name if colors.sjis_name is not None else colors.name.encode("cp932", "replace")
|
|
80
|
+
utf8 = colors.name.encode("utf-8")
|
|
81
|
+
block = struct.pack("<H", len(sjis)) + sjis + struct.pack("<IH", colors.after_sjis, len(utf8)) + utf8
|
|
82
|
+
entries = bytearray()
|
|
83
|
+
for color in colors.colors:
|
|
84
|
+
entry = bytearray(color.rgba)
|
|
85
|
+
if color.name is not None:
|
|
86
|
+
units = color.name.encode("utf-16-le")
|
|
87
|
+
entry += struct.pack("<IH", max(1, color.flag), len(units)) + units
|
|
88
|
+
else:
|
|
89
|
+
entry += struct.pack("<I", color.flag)
|
|
90
|
+
entry += color.extra
|
|
91
|
+
entries += struct.pack("<I", len(entry)) + entry
|
|
92
|
+
head = b"SLCC" + struct.pack("<HI", colors.version, len(block)) + block
|
|
93
|
+
return head + struct.pack("<III", colors.format, len(colors.colors), len(entries)) + bytes(entries)
|