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

1"""Qt-independent project (.zip) serialization. 

2 

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 

14 

15from .i18n import tr 

16from .errors import OperationError 

17 

18from PySide6.QtCore import QBuffer, QByteArray, QIODevice, Qt 

19from PySide6.QtGui import QImage, QImageReader 

20 

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 

35 

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 

40 

41log = get_logger(__name__) 

42 

43# ファイルのアイコンに使う縮小画像。Finder(Quick Look の拡張)とエクスプローラー 

44# (サムネイルの拡張)が zip を解かずに読めるよう、無圧縮でアーカイブの先頭に置く。 

45THUMBNAIL_NAME = "thumbnail.png" 

46THUMBNAIL_SIZE = 512 

47 

48 

49class PngCache: 

50 """変更のないセルの PNG を保存のたびに再エンコードしないためのキャッシュ。 

51 

52 キーは ``QImage.cacheKey()``。QImage は画素を書き換えると(QPainter や 

53 ``bits()`` 経由でも)キーが変わるので、キーが同じなら前回のPNGをそのまま 

54 使える。保存のたびに今回使ったセルだけを残し、消えたセルの分は捨てる。 

55 """ 

56 

57 def __init__(self): 

58 self._entries = {} 

59 

60 def __len__(self): 

61 return len(self._entries) 

62 

63 def clear(self): 

64 self._entries.clear() 

65 

66 def remember(self, image, data): 

67 self._entries[int(image.cacheKey())] = bytes(data) 

68 

69 def encode_many(self, images, used_keys=None): 

70 """``images`` の PNG を ``{cacheKey: bytes | None}`` で返す。 

71 

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 

92 

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] 

96 

97 

98def parallel_map(function, items): 

99 """``map`` を複数スレッドで行う(順番は保つ)。数が少なければそのまま回す。 

100 

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)) 

109 

110 

111_MAX_WORKERS = 8 

112 

113 

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()) 

125 

126 

127def _decode_png(data): 

128 """PNG を読み、キャンバスと同じ形式(乗算済み ARGB)にする。失敗なら null。 

129 

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) 

143 

144 

145def _prefetch_pngs(archive, entries, width, height, decoded): 

146 """``entries``(project.json の各セル)の PNG を先にまとめて並列に読む。 

147 

148 ``{パス: (PNG のバイト列, 画像)}`` を返す。参照が不正なものはここでは 

149 読まずに飛ばし、本体のループで従来どおりのエラーにする。 

150 

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 } 

189 

190 

191def read_project_archive(path, png_cache=None): 

192 """Open and validate a project archive. 

193 

194 Returns (metadata, frames, width, height). Raises ValueError on any 

195 malformed/oversized/unsafe archive (the caller shows the UI error). 

196 

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("対応していないプロジェクト形式です。")) 

229 

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 

238 

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("キャンバスサイズが不正です。")) 

244 

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("フレーム数が上限を超えています。")) 

251 

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("プロジェクトのセル数が上限を超えています。")) 

269 

270 decoded = {} 

271 # 旧形式の白→透明の変換も、共有している画像ごとに1回だけ行う。 

272 migrated = {} 

273 

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]) 

279 

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 

325 

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)) 

378 

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 ) 

453 

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 

565 

566 

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 

583 

584 

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``. 

590 

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). 

594 

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. 

599 

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 ) 

621 

622 # まず書き出すセルを集め、PNG はあとでまとめて並列にエンコードする。 

623 pending_pngs = [] 

624 

625 def write_png(image_name, image, message): 

626 pending_pngs.append((image_name, image, message)) 

627 

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 ) 

643 

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 ) 

655 

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) 

686 

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 ) 

728 

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 ) 

739 

740 archive.writestr( 

741 "project.json", 

742 json.dumps( 

743 metadata, 

744 ensure_ascii=False, 

745 indent=2, 

746 ).encode("utf-8"), 

747 ) 

748 

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)