Coverage for paintmaskanimator/project_io.py: 78%
345 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"""Qt-independent project (.zip) serialization.
3Reads/writes the PaintMaskAnimator project archive format (a zip holding
4project.json plus one PNG per layer-cell). Works with Frame/Layer domain
5objects and returns/consumes plain data, so MainWindow keeps only the
6widget<->metadata mapping and the UI feedback.
7"""
8import json
9import math
10import os
11from concurrent.futures import ThreadPoolExecutor
12import zipfile
13from pathlib import Path, PurePosixPath
15from .i18n import tr
16from .errors import OperationError
18from PySide6.QtCore import QBuffer, QByteArray, QIODevice, Qt
19from PySide6.QtGui import QImage, QImageReader
21from .constants import (
22 MAX_PROJECT_ARCHIVE_BYTES,
23 MAX_PROJECT_DECODED_PIXELS,
24 MAX_PROJECT_FRAMES,
25 MAX_PROJECT_IMAGE_BYTES,
26 MAX_PROJECT_LAYER_CELLS,
27 MAX_PROJECT_LAYERS,
28 MAX_PROJECT_METADATA_BYTES,
29)
30from .imaging import white_to_transparent_qimage
31from .models import Frame, Layer, default_layer_name
32from .tween_groups import sanitize_spec
33from .utils import blank_image
34from .logging_setup import get_logger
36# Bump when the on-disk pixel contract changes. mask_format >= 2 stores erased
37# areas as alpha=0; format 1 (or missing) stored them as pseudo-transparent
38# opaque #FFFFFF and needs a white->transparent migration on load.
39CURRENT_MASK_FORMAT = 2
41log = get_logger(__name__)
43# ファイルのアイコンに使う縮小画像。Finder(Quick Look の拡張)とエクスプローラー
44# (サムネイルの拡張)が zip を解かずに読めるよう、無圧縮でアーカイブの先頭に置く。
45THUMBNAIL_NAME = "thumbnail.png"
46THUMBNAIL_SIZE = 512
49class PngCache:
50 """変更のないセルの PNG を保存のたびに再エンコードしないためのキャッシュ。
52 キーは ``QImage.cacheKey()``。QImage は画素を書き換えると(QPainter や
53 ``bits()`` 経由でも)キーが変わるので、キーが同じなら前回のPNGをそのまま
54 使える。保存のたびに今回使ったセルだけを残し、消えたセルの分は捨てる。
55 """
57 def __init__(self):
58 self._entries = {}
60 def __len__(self):
61 return len(self._entries)
63 def clear(self):
64 self._entries.clear()
66 def remember(self, image, data):
67 self._entries[int(image.cacheKey())] = bytes(data)
69 def encode_many(self, images, used_keys=None):
70 """``images`` の PNG を ``{cacheKey: bytes | None}`` で返す。
72 キャッシュに無いものだけを、複数のスレッドで同時にエンコードする。
73 QImage.save は実行中 GIL を手放すので、コア数に近い倍率で速くなる
74 (1920x1080 で1枚約60ms かかり、全セルの書き出しが数十秒になっていた)。
75 """
76 result = {}
77 missing = {}
78 for image in images:
79 key = int(image.cacheKey())
80 if used_keys is not None: 80 ↛ 82line 80 didn't jump to line 82 because the condition on line 80 was always true
81 used_keys.add(key)
82 data = self._entries.get(key)
83 if data is not None:
84 result[key] = data
85 elif key not in missing:
86 missing[key] = image
87 for key, data in zip(missing, parallel_map(encode_png, missing.values())):
88 result[key] = data
89 if data is not None: 89 ↛ 87line 89 didn't jump to line 87 because the condition on line 89 was always true
90 self._entries[key] = data
91 return result
93 def retain_only(self, keys):
94 for key in [key for key in self._entries if key not in keys]: 94 ↛ 95line 94 didn't jump to line 95 because the loop on line 94 never started
95 del self._entries[key]
98def parallel_map(function, items):
99 """``map`` を複数スレッドで行う(順番は保つ)。数が少なければそのまま回す。
101 QImage のエンコード・デコードのように、GIL を手放す Qt の処理に使う。
102 """
103 items = list(items)
104 workers = min(_MAX_WORKERS, os.cpu_count() or 1, len(items))
105 if workers <= 1:
106 return [function(item) for item in items]
107 with ThreadPoolExecutor(max_workers=workers) as executor:
108 return list(executor.map(function, items))
111_MAX_WORKERS = 8
114def encode_png(image):
115 """QImage を PNG のバイト列にする。失敗したら None。"""
116 data = QByteArray()
117 buffer = QBuffer(data)
118 buffer.open(QIODevice.OpenModeFlag.WriteOnly)
119 try:
120 if not image.save(buffer, "PNG"): 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true
121 return None
122 finally:
123 buffer.close()
124 return bytes(data.data())
127def _decode_png(data):
128 """PNG を読み、キャンバスと同じ形式(乗算済み ARGB)にする。失敗なら null。
130 QImage.fromData は読み込み中も GIL を握ったままなので、スレッドに分けても
131 速くならない。QImageReader.read は GIL を手放すので、こちらを使う。
132 """
133 buffer = QBuffer()
134 buffer.setData(QByteArray(data))
135 buffer.open(QIODevice.OpenModeFlag.ReadOnly)
136 try:
137 image = QImageReader(buffer, b"png").read()
138 finally:
139 buffer.close()
140 if image.isNull(): 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 return image
142 return image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
145def _prefetch_pngs(archive, entries, width, height, decoded):
146 """``entries``(project.json の各セル)の PNG を先にまとめて並列に読む。
148 ``{パス: (PNG のバイト列, 画像)}`` を返す。参照が不正なものはここでは
149 読まずに飛ばし、本体のループで従来どおりのエラーにする。
151 中身が同じ PNG(空のセルなど)は1回だけデコードし、画像を暗黙共有する。
152 編集中も空のセルは1枚の雛形を共有しているので、読み込みで枚数分の画素を
153 確保すると、保存できたプロジェクトが開けなくなる。展開後の画素数の上限も
154 この中身の種類数で数える。``decoded``({PNG のバイト列: 画像})は
155 呼び出しをまたいで共有する。
156 """
157 paths = []
158 seen = set()
159 for entry in entries:
160 if not isinstance(entry, dict): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 continue
162 image_path = str(entry.get("image") or "")
163 normalized = PurePosixPath(image_path)
164 if ( 164 ↛ 171line 164 didn't jump to line 171 because the condition on line 164 was never true
165 not image_path
166 or image_path in seen
167 or normalized.is_absolute()
168 or ".." in normalized.parts
169 or normalized.suffix.lower() != ".png"
170 ):
171 continue
172 try:
173 info = archive.getinfo(image_path)
174 except KeyError:
175 continue
176 if info.file_size > MAX_PROJECT_IMAGE_BYTES: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 continue
178 seen.add(image_path)
179 paths.append(image_path)
180 blobs = [archive.read(image_path) for image_path in paths]
181 pending = list(dict.fromkeys(data for data in blobs if data not in decoded))
182 if (len(decoded) + len(pending)) * width * height > MAX_PROJECT_DECODED_PIXELS:
183 raise OperationError(tr("プロジェクトの展開後画像サイズが大きすぎます。"))
184 decoded.update(zip(pending, parallel_map(_decode_png, pending)))
185 return {
186 image_path: (data, decoded[data])
187 for image_path, data in zip(paths, blobs)
188 }
191def read_project_archive(path, png_cache=None):
192 """Open and validate a project archive.
194 Returns (metadata, frames, width, height). Raises ValueError on any
195 malformed/oversized/unsafe archive (the caller shows the UI error).
197 ``png_cache`` (a ``PngCache``) is reset and seeded with each cell's PNG
198 bytes from the archive, so the first save after opening only re-encodes
199 the cells that were edited.
200 """
201 # 途中で読み込みに失敗したときに今のキャッシュを壊さないよう、
202 # 成功してから差し替える。
203 seeded = []
204 project_path = Path(path)
205 with zipfile.ZipFile(project_path, "r") as archive:
206 archive_entries = archive.infolist()
207 # セルの画像と、保持した初期状態(あれば)で、セル1つにつき最大2ファイル。
208 # ほかに project.json とサムネイル。
209 if len(archive_entries) > 2 * MAX_PROJECT_LAYER_CELLS + 2: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 raise OperationError(tr("プロジェクト内のファイル数が多すぎます。"))
211 if any(info.flag_bits & 0x1 for info in archive_entries): 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true
212 raise OperationError(tr("暗号化されたプロジェクトには対応していません。"))
213 if sum(int(info.file_size) for info in archive_entries) > MAX_PROJECT_ARCHIVE_BYTES: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true
214 raise OperationError(tr("プロジェクトの展開後サイズが大きすぎます。"))
215 entry_names = [info.filename for info in archive_entries]
216 if len(entry_names) != len(set(entry_names)): 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 raise OperationError(tr("プロジェクト内に重複したファイル名があります。"))
218 try:
219 metadata_info = archive.getinfo("project.json")
220 except KeyError as error:
221 raise OperationError(tr("project.jsonがありません。")) from error
222 if metadata_info.file_size > MAX_PROJECT_METADATA_BYTES: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
223 raise OperationError(tr("プロジェクト情報が大きすぎます。"))
224 metadata = json.loads(
225 archive.read("project.json").decode("utf-8")
226 )
227 if metadata.get("format") != "PaintMaskAnimatorProject":
228 raise OperationError(tr("対応していないプロジェクト形式です。"))
230 try:
231 mask_format = int(metadata.get("mask_format", 1))
232 except (TypeError, ValueError):
233 mask_format = 1
234 # Legacy files stored erased pixels as opaque #FFFFFF; convert them to
235 # real transparency so they read the same now that pseudo-transparency
236 # is gone. The paper layer legitimately stays white and is skipped.
237 needs_white_migration = mask_format < CURRENT_MASK_FORMAT
239 canvas_data = metadata.get("canvas", {})
240 width = int(canvas_data.get("width", 1280))
241 height = int(canvas_data.get("height", 720))
242 if not (1 <= width <= 16384 and 1 <= height <= 16384): 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 raise OperationError(tr("キャンバスサイズが不正です。"))
245 loaded_frames = []
246 frame_entries = metadata.get("frames", [])
247 if not isinstance(frame_entries, list) or not frame_entries: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 raise OperationError(tr("フレーム情報がありません。"))
249 if len(frame_entries) > MAX_PROJECT_FRAMES: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
250 raise OperationError(tr("フレーム数が上限を超えています。"))
252 expected_layer_count = None
253 layer_cell_count = 0
254 for frame_data in frame_entries:
255 if not isinstance(frame_data, dict): 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true
256 raise OperationError(tr("フレーム情報が不正です。"))
257 layer_entries = frame_data.get("layers", [])
258 if not isinstance(layer_entries, list) or not layer_entries: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true
259 raise OperationError(tr("レイヤー情報がありません。"))
260 if len(layer_entries) > MAX_PROJECT_LAYERS: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true
261 raise OperationError(tr("レイヤー数が上限を超えています。"))
262 if expected_layer_count is None:
263 expected_layer_count = len(layer_entries)
264 elif len(layer_entries) != expected_layer_count: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true
265 raise OperationError(tr("フレームごとのレイヤー数が一致していません。"))
266 layer_cell_count += len(layer_entries)
267 if layer_cell_count > MAX_PROJECT_LAYER_CELLS: 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true
268 raise OperationError(tr("プロジェクトのセル数が上限を超えています。"))
270 decoded = {}
271 # 旧形式の白→透明の変換も、共有している画像ごとに1回だけ行う。
272 migrated = {}
274 def migrate_white(image):
275 key = image.cacheKey()
276 if key not in migrated:
277 migrated[key] = white_to_transparent_qimage(image)
278 return QImage(migrated[key])
280 prefetched = _prefetch_pngs(
281 archive,
282 (
283 layer_data
284 for frame_data in frame_entries
285 for layer_data in frame_data.get("layers", [])
286 ),
287 width,
288 height,
289 decoded,
290 )
291 def load_initial(layer_data):
292 """保持した色塗り前の初期状態。無ければ None。"""
293 line_path = layer_data.get("initial")
294 if not line_path:
295 return None
296 line_path = str(line_path)
297 normalized = PurePosixPath(line_path)
298 if ( 298 ↛ 303line 298 didn't jump to line 303 because the condition on line 298 was never true
299 normalized.is_absolute()
300 or ".." in normalized.parts
301 or normalized.suffix.lower() != ".png"
302 ):
303 raise OperationError(tr("初期状態の画像の参照パスが不正です。"))
304 try:
305 line_info = archive.getinfo(line_path)
306 except KeyError as error:
307 raise OperationError(
308 tr("初期状態の画像がありません: {path}").format(path=line_path)
309 ) from error
310 if line_info.file_size > MAX_PROJECT_IMAGE_BYTES: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true
311 raise OperationError(tr("初期状態の画像が大きすぎます: {path}").format(path=line_path))
312 line_bytes = archive.read(line_path)
313 line_image = _decode_png(line_bytes)
314 if ( 314 ↛ 319line 314 didn't jump to line 319 because the condition on line 314 was never true
315 line_image.isNull()
316 or line_image.width() != width
317 or line_image.height() != height
318 ):
319 raise OperationError(
320 tr("初期状態の画像を復元できません: {path}").format(path=line_path)
321 )
322 if png_cache is not None: 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true
323 seeded.append((line_image, line_bytes))
324 return line_image
326 for frame_data in frame_entries:
327 loaded_layers = []
328 for layer_data in frame_data.get("layers", []):
329 if not isinstance(layer_data, dict): 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true
330 raise OperationError(tr("レイヤー情報が不正です。"))
331 image_path = layer_data.get("image")
332 if not image_path: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 raise OperationError(tr("レイヤー画像の参照がありません。"))
334 image_path = str(image_path)
335 normalized_path = PurePosixPath(image_path)
336 if ( 336 ↛ 341line 336 didn't jump to line 341 because the condition on line 336 was never true
337 normalized_path.is_absolute()
338 or ".." in normalized_path.parts
339 or normalized_path.suffix.lower() != ".png"
340 ):
341 raise OperationError(tr("レイヤー画像の参照パスが不正です。"))
342 try:
343 image_info = archive.getinfo(image_path)
344 except KeyError as error:
345 raise OperationError(
346 tr("レイヤー画像がありません: {path}").format(path=image_path)
347 ) from error
348 if image_info.file_size > MAX_PROJECT_IMAGE_BYTES: 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true
349 raise OperationError(
350 tr("レイヤー画像が大きすぎます: {path}").format(path=image_path)
351 )
352 if image_path in prefetched: 352 ↛ 357line 352 didn't jump to line 357 because the condition on line 352 was always true
353 image_bytes, image = prefetched[image_path]
354 # セルごとに別の QImage にする(画素は書き込むまで共有される)。
355 image = QImage(image)
356 else:
357 image_bytes = archive.read(image_path)
358 image = _decode_png(image_bytes)
359 if image.isNull(): 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true
360 raise OperationError(
361 tr("レイヤー画像を復元できません: {path}").format(path=image_path)
362 )
363 if image.width() != width or image.height() != height: 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true
364 raise OperationError(
365 tr("レイヤー画像のサイズが不正です: {path}").format(path=image_path)
366 )
367 is_draft = bool(layer_data.get("is_draft", False))
368 # 下書きレイヤーは読み込んだ画素をそのまま保持するため、
369 # 用紙レイヤーと同様に白→透明の移行対象から除外する。
370 if (
371 needs_white_migration
372 and not bool(layer_data.get("is_paper", False))
373 and not is_draft
374 ):
375 image = migrate_white(image)
376 elif png_cache is not None:
377 seeded.append((image, image_bytes))
379 filter_rgb = layer_data.get("color_filter_rgb")
380 exposure = int(layer_data.get("exposure", 1))
381 if not (1 <= exposure <= MAX_PROJECT_FRAMES): 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true
382 raise OperationError(tr("レイヤーの露出フレーム数が不正です。"))
383 sequence_number = layer_data.get("sequence_number")
384 if sequence_number is not None:
385 sequence_number = int(sequence_number)
386 if not (1 <= sequence_number <= MAX_PROJECT_FRAMES): 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true
387 raise OperationError(tr("絵番号が範囲外です。"))
388 opacity = float(layer_data.get("opacity", 1.0))
389 if not math.isfinite(opacity): 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true
390 raise OperationError(tr("レイヤー不透明度が不正です。"))
391 loaded_layers.append(
392 Layer(
393 str(layer_data.get("name", "Layer")),
394 image,
395 bool(layer_data.get("visible", True)),
396 max(0.0, min(1.0, opacity)),
397 bool(layer_data.get("is_paper", False)),
398 bool(layer_data.get("has_content", False)),
399 False, # legacy alpha lock is intentionally ignored
400 exposure,
401 bool(
402 layer_data.get(
403 "color_filter_enabled",
404 False,
405 )
406 ),
407 (
408 tuple(int(v) for v in filter_rgb[:3])
409 if filter_rgb is not None
410 else None
411 ),
412 bool(
413 layer_data.get(
414 "is_blank_key",
415 (
416 not bool(
417 layer_data.get(
418 "has_content",
419 False,
420 )
421 )
422 and max(
423 1,
424 int(
425 layer_data.get(
426 "exposure",
427 1,
428 )
429 ),
430 ) > 1
431 ),
432 )
433 ),
434 (
435 sequence_number
436 ),
437 bool(layer_data.get("sequence_only", False)),
438 is_draft,
439 (
440 str(layer_data.get("cell_name"))
441 if layer_data.get("cell_name") is not None
442 else None
443 ),
444 sanitize_spec(layer_data.get("tween")),
445 (
446 str(layer_data.get("tween_member"))
447 if layer_data.get("tween_member") is not None
448 else None
449 ),
450 load_initial(layer_data),
451 )
452 )
454 if not loaded_layers: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true
455 loaded_layers = [Layer(default_layer_name(0), blank_image())]
456 loaded_frames.append(
457 Frame(
458 loaded_layers,
459 max(
460 1,
461 min(
462 MAX_PROJECT_FRAMES,
463 int(frame_data.get("duration", 1)),
464 ),
465 ),
466 )
467 )
468 sequence_archive_data = metadata.get("sequence_archive", [])
469 if not isinstance(sequence_archive_data, list): 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true
470 raise OperationError(tr("連番保管セル情報が不正です。"))
471 if layer_cell_count + len(sequence_archive_data) > MAX_PROJECT_LAYER_CELLS: 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true
472 raise OperationError(tr("プロジェクトのセル数が上限を超えています。"))
473 loaded_sequence_archive = {}
474 prefetched = _prefetch_pngs(
475 archive, sequence_archive_data, width, height, decoded
476 )
477 for archive_data in sequence_archive_data:
478 if not isinstance(archive_data, dict): 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true
479 raise OperationError(tr("連番保管セル情報が不正です。"))
480 layer_index = int(archive_data.get("layer_index", -1))
481 number = int(archive_data.get("sequence_number", 0))
482 if not ( 482 ↛ 487line 482 didn't jump to line 487 because the condition on line 482 was never true
483 expected_layer_count is not None
484 and 0 <= layer_index < expected_layer_count
485 and 1 <= number <= MAX_PROJECT_FRAMES
486 ):
487 raise OperationError(tr("連番保管セルのレイヤーまたは絵番号が不正です。"))
488 key = (layer_index, number)
489 if key in loaded_sequence_archive: 489 ↛ 490line 489 didn't jump to line 490 because the condition on line 489 was never true
490 raise OperationError(tr("連番保管セルの絵番号が重複しています。"))
491 image_path = str(archive_data.get("image", ""))
492 normalized_path = PurePosixPath(image_path)
493 if ( 493 ↛ 499line 493 didn't jump to line 499 because the condition on line 493 was never true
494 not image_path
495 or normalized_path.is_absolute()
496 or ".." in normalized_path.parts
497 or normalized_path.suffix.lower() != ".png"
498 ):
499 raise OperationError(tr("連番保管セル画像の参照パスが不正です。"))
500 try:
501 image_info = archive.getinfo(image_path)
502 except KeyError as error:
503 raise OperationError(
504 tr("連番保管セル画像がありません: {path}").format(path=image_path)
505 ) from error
506 if image_info.file_size > MAX_PROJECT_IMAGE_BYTES: 506 ↛ 507line 506 didn't jump to line 507 because the condition on line 506 was never true
507 raise OperationError(
508 tr("連番保管セル画像が大きすぎます: {path}").format(path=image_path)
509 )
510 if image_path in prefetched: 510 ↛ 514line 510 didn't jump to line 514 because the condition on line 510 was always true
511 image_bytes, image = prefetched[image_path]
512 image = QImage(image)
513 else:
514 image_bytes = archive.read(image_path)
515 image = _decode_png(image_bytes)
516 if ( 516 ↛ 521line 516 didn't jump to line 521 because the condition on line 516 was never true
517 image.isNull()
518 or image.width() != width
519 or image.height() != height
520 ):
521 raise OperationError(
522 tr("連番保管セル画像を復元できません: {path}").format(path=image_path)
523 )
524 is_draft = bool(archive_data.get("is_draft", False))
525 if needs_white_migration and not is_draft:
526 image = migrate_white(image)
527 elif png_cache is not None: 527 ↛ 528line 527 didn't jump to line 528 because the condition on line 527 was never true
528 seeded.append((image, image_bytes))
529 opacity = float(archive_data.get("opacity", 1.0))
530 if not math.isfinite(opacity): 530 ↛ 531line 530 didn't jump to line 531 because the condition on line 530 was never true
531 raise OperationError(tr("連番保管セルの不透明度が不正です。"))
532 filter_rgb = archive_data.get("color_filter_rgb")
533 loaded_sequence_archive[key] = Layer(
534 str(archive_data.get("name", "Layer")),
535 image,
536 bool(archive_data.get("visible", True)),
537 max(0.0, min(1.0, opacity)),
538 False,
539 bool(archive_data.get("has_content", True)),
540 False,
541 1,
542 bool(archive_data.get("color_filter_enabled", False)),
543 (
544 tuple(int(value) for value in filter_rgb[:3])
545 if filter_rgb is not None
546 else None
547 ),
548 bool(archive_data.get("is_blank_key", False)),
549 number,
550 False,
551 is_draft,
552 (
553 str(archive_data.get("cell_name"))
554 if archive_data.get("cell_name") is not None
555 else None
556 ),
557 )
558 loaded_sequence_archive[key].initial_image = load_initial(archive_data)
559 metadata["_loaded_sequence_archive"] = loaded_sequence_archive
560 if png_cache is not None:
561 png_cache.clear()
562 for image, image_bytes in seeded:
563 png_cache.remember(image, image_bytes)
564 return metadata, loaded_frames, width, height
567def _thumbnail_png(image):
568 """``image`` を長辺 ``THUMBNAIL_SIZE`` 以下に縮めた PNG のバイト列。"""
569 if image is None or image.isNull():
570 return None
571 if max(image.width(), image.height()) > THUMBNAIL_SIZE:
572 image = image.scaled(
573 THUMBNAIL_SIZE, THUMBNAIL_SIZE,
574 Qt.AspectRatioMode.KeepAspectRatio,
575 Qt.TransformationMode.SmoothTransformation,
576 )
577 data = QByteArray()
578 buffer = QBuffer(data)
579 buffer.open(QIODevice.OpenModeFlag.WriteOnly)
580 ok = image.save(buffer, "PNG")
581 buffer.close()
582 return bytes(data.data()) if ok else None
585def write_project_archive(
586 project_path, metadata, frames, sequence_archive=None, png_cache=None,
587 thumbnail=None,
588):
589 """Serialize frames + metadata into the project archive at ``project_path``.
591 Fills ``metadata["frames"]`` from the Frame/Layer domain objects, writes one
592 PNG per layer-cell plus project.json into a zip, and atomically replaces the
593 destination. Raises on error (the caller shows the UI message).
595 With ``png_cache`` (a ``PngCache``), cells unchanged since the last save or
596 open reuse their PNG bytes instead of being re-encoded. PNG entries are
597 stored without zip compression: they are already deflated, so compressing
598 them again only costs time.
600 ``thumbnail`` (a QImage, e.g. the current frame) is shrunk and stored as
601 the first entry so the OS file-icon extensions can find it cheaply.
602 """
603 project_path = Path(project_path)
604 metadata.pop("_loaded_sequence_archive", None)
605 project_path.parent.mkdir(parents=True, exist_ok=True)
606 temporary_path = project_path.with_suffix(project_path.suffix + ".tmp")
607 cache = png_cache if png_cache is not None else PngCache()
608 used_keys = set()
609 try:
610 with zipfile.ZipFile(
611 temporary_path,
612 "w",
613 compression=zipfile.ZIP_DEFLATED,
614 compresslevel=6,
615 ) as archive:
616 thumbnail_png = _thumbnail_png(thumbnail)
617 if thumbnail_png is not None:
618 archive.writestr(
619 THUMBNAIL_NAME, thumbnail_png, compress_type=zipfile.ZIP_STORED
620 )
622 # まず書き出すセルを集め、PNG はあとでまとめて並列にエンコードする。
623 pending_pngs = []
625 def write_png(image_name, image, message):
626 pending_pngs.append((image_name, image, message))
628 for frame_index, frame in enumerate(frames):
629 frame_data = {
630 "duration": int(frame.duration),
631 "layers": [],
632 }
633 for layer_index, layer in enumerate(frame.layers):
634 image_name = (
635 f"layers/layer_{layer_index:04d}/"
636 f"frame_{frame_index:06d}.png"
637 )
638 write_png(
639 image_name,
640 layer.image,
641 tr("画像を書き出せませんでした: {name}"),
642 )
644 initial_name = None
645 if getattr(layer, "initial_image", None) is not None:
646 initial_name = (
647 f"initial/layer_{layer_index:04d}/"
648 f"frame_{frame_index:06d}.png"
649 )
650 write_png(
651 initial_name,
652 layer.initial_image,
653 tr("初期状態の画像を書き出せませんでした: {name}"),
654 )
656 frame_data["layers"].append(
657 {
658 "name": layer.name,
659 "image": image_name,
660 "initial": initial_name,
661 "visible": bool(layer.visible),
662 "opacity": float(layer.opacity),
663 "is_paper": bool(layer.is_paper),
664 "has_content": bool(layer.has_content),
665 "exposure": int(layer.exposure),
666 "is_blank_key": bool(
667 getattr(layer, "is_blank_key", False)
668 ),
669 "sequence_number": layer.sequence_number,
670 "sequence_only": bool(layer.sequence_only),
671 "is_draft": bool(getattr(layer, "is_draft", False)),
672 "cell_name": getattr(layer, "cell_name", None),
673 "tween": getattr(layer, "tween", None),
674 "tween_member": getattr(layer, "tween_member", None),
675 "color_filter_enabled": bool(
676 layer.color_filter_enabled
677 ),
678 "color_filter_rgb": (
679 list(layer.color_filter_rgb)
680 if layer.color_filter_rgb is not None
681 else None
682 ),
683 }
684 )
685 metadata["frames"].append(frame_data)
687 metadata["sequence_archive"] = []
688 for (layer_index, number), layer in sorted(
689 (sequence_archive or {}).items()
690 ):
691 image_name = (
692 f"sequence_archive/layer_{int(layer_index):04d}/"
693 f"cell_{int(number):06d}.png"
694 )
695 write_png(
696 image_name,
697 layer.image,
698 tr("連番保管セルを書き出せませんでした: {name}"),
699 )
700 initial_name = None
701 if layer.initial_image is not None:
702 initial_name = f"initial/{image_name}"
703 write_png(initial_name, layer.initial_image,
704 tr("初期状態の画像を書き出せませんでした: {name}"))
705 metadata["sequence_archive"].append(
706 {
707 "layer_index": int(layer_index),
708 "sequence_number": int(number),
709 "image": image_name,
710 "initial": initial_name,
711 "name": layer.name,
712 "visible": bool(layer.visible),
713 "opacity": float(layer.opacity),
714 "has_content": bool(layer.has_content),
715 "is_blank_key": bool(layer.is_blank_key),
716 "is_draft": bool(getattr(layer, "is_draft", False)),
717 "cell_name": getattr(layer, "cell_name", None),
718 "color_filter_enabled": bool(
719 layer.color_filter_enabled
720 ),
721 "color_filter_rgb": (
722 list(layer.color_filter_rgb)
723 if layer.color_filter_rgb is not None
724 else None
725 ),
726 }
727 )
729 encoded = cache.encode_many(
730 (image for _name, image, _message in pending_pngs), used_keys
731 )
732 for image_name, image, message in pending_pngs:
733 data = encoded.get(int(image.cacheKey()))
734 if data is None: 734 ↛ 735line 734 didn't jump to line 735 because the condition on line 734 was never true
735 raise OperationError(message.format(name=image_name))
736 archive.writestr(
737 image_name, data, compress_type=zipfile.ZIP_STORED
738 )
740 archive.writestr(
741 "project.json",
742 json.dumps(
743 metadata,
744 ensure_ascii=False,
745 indent=2,
746 ).encode("utf-8"),
747 )
749 temporary_path.replace(project_path)
750 except Exception:
751 # Any failure while writing leaves a half-written temp file; remove it
752 # and re-raise so the caller sees the original error.
753 try:
754 temporary_path.unlink(missing_ok=True)
755 except OSError as exc:
756 log.debug("could not remove temp project file: %s", exc)
757 raise
758 # 消えたセル・描き換えたセルの古いPNGを持ち続けない。
759 cache.retain_only(used_keys)