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 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
+ ]
@@ -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"]
@@ -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)