Coverage for paintmaskanimator/main_window_export.py: 61%

407 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Export dialogs (XDTS / PSD / key-sequence images / cut folder / MP4). 

2 

3A *collaborator* of ``MainWindow`` rather than a mixin: the window owns one as 

4``window.export`` and the dependency runs one way only, through the ``window`` 

5handle taken in ``__init__``. That handle is also the parent for the file and 

6message dialogs raised here. 

7 

8Being a plain object (not part of the window's namespace) means these methods 

9cannot collide with a sibling's, and the attributes they rely on -- ``canvas``, 

10``timeline``, the progress-counter helpers -- are visible as ``self.window.…`` 

11instead of appearing out of nowhere. This is the shape the remaining 

12``main_window_*`` mixins are being migrated to. 

13""" 

14from typing import TYPE_CHECKING 

15 

16import csv 

17import datetime as _datetime 

18import shutil 

19import subprocess 

20import sys 

21import tempfile 

22from pathlib import Path 

23from PySide6.QtWidgets import (QApplication, QDialog, QFileDialog, QInputDialog, 

24 QMessageBox) 

25from .i18n import tr 

26from .memo_window import MEMO_FILE_NAME 

27from . import movie_slate 

28from . import cut_folder, imaging, preferences, shadow_export, timesheet_file 

29from .models import default_layer_name 

30from .optional_deps import PILImage, PSDImage 

31from .constants import OUTSIDE_MARGIN 

32from . import constants 

33from .canvas import PaintCanvas 

34from .errors import OPERATION_ERRORS as _OPERATION_ERRORS, OperationError 

35from .timeline import TimelineWidget 

36from .progress import close_counter, create_counter, update_counter 

37from .logging_setup import get_logger 

38 

39if TYPE_CHECKING: 

40 from .main_window import MainWindow 

41 

42log = get_logger(__name__) 

43 

44 

45class ExportController: 

46 """Exports the current document; owned by ``MainWindow`` as ``window.export``.""" 

47 

48 def __init__(self, window: "MainWindow"): 

49 self.window = window 

50 

51 # -- 今の絵の書き出し(キャンバス下のバー) -------------------------------- 

52 

53 def canvas_image(self): 

54 """今表示しているコマの、キャンバス範囲の合成画像。""" 

55 return self.window.crop_image(self.window.canvas.current_frame) 

56 

57 @staticmethod 

58 def snapshot_path(folder): 

59 """``folder`` に置く、年月日時分秒(14桁)の .png のパス。 

60 

61 同じ秒に2枚撮ると名前がぶつかるので、そのときだけ連番を足して 

62 前の1枚を黙って上書きしないようにする。 

63 """ 

64 stamp = _datetime.datetime.now().strftime("%Y%m%d%H%M%S") 

65 path = folder / f"{stamp}.png" 

66 suffix = 2 

67 while path.exists(): 

68 path = folder / f"{stamp}_{suffix}.png" 

69 suffix += 1 

70 return path 

71 

72 def copy_canvas_to_clipboard(self): 

73 """今の絵をクリップボードへ書き出す。""" 

74 clipboard = QApplication.clipboard() 

75 if clipboard is None: 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 return False 

77 clipboard.setImage(self.canvas_image()) 

78 self.window.statusBar().showMessage( 

79 tr("今の絵をクリップボードへ書き出しました。"), 4000 

80 ) 

81 return True 

82 

83 def choose_snapshot_folder(self): 

84 """カメラボタンの保存先を選び直す。選んだら真。""" 

85 folder = QFileDialog.getExistingDirectory( 

86 self.window, 

87 tr("画像の保存先フォルダー"), 

88 preferences.snapshot_folder() or "", 

89 ) 

90 if not folder: 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true

91 return False 

92 preferences.set_snapshot_folder(folder) 

93 return True 

94 

95 def save_canvas_snapshot(self, choose_folder=False): 

96 """今の絵を保存先フォルダーへ年月日時分秒の名前で書き出す。 

97 

98 保存先が未設定・見つからない場合と ``choose_folder`` のときだけ尋ねる。 

99 あとはボタン1回で1枚ずつたまる。 

100 """ 

101 folder = Path(preferences.snapshot_folder()) 

102 if choose_folder or not preferences.snapshot_folder() or not folder.is_dir(): 

103 if not self.choose_snapshot_folder(): 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 return False 

105 folder = Path(preferences.snapshot_folder()) 

106 path = self.snapshot_path(folder) 

107 try: 

108 saved = self.canvas_image().save(str(path), "PNG") # pyright: ignore[reportCallIssue,reportArgumentType] # QImage.save accepts a str format at runtime 

109 except OSError as exc: 

110 log.error("snapshot save failed: %s", exc, exc_info=True) 

111 saved = False 

112 if not saved: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true

113 QMessageBox.warning( 

114 self.window, 

115 tr("画像を保存"), 

116 tr("画像を保存できませんでした:{path}").format(path=path), 

117 ) 

118 return False 

119 self.window.statusBar().showMessage( 

120 tr("「{name}」として保存しました。").format(name=path.name), 4000 

121 ) 

122 return True 

123 

124 def xdts_dialog(self): 

125 path, _ = QFileDialog.getSaveFileName( 

126 self.window, 

127 tr("XDTSタイムシートを書き出す"), 

128 "PaintMaskAnimator.xdts", 

129 tr("XDTSタイムシート (*.xdts)"), 

130 ) 

131 if not path: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true

132 return 

133 if not path.lower().endswith(".xdts"): 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true

134 path += ".xdts" 

135 try: 

136 Path(path).write_text(self.xdts_text(), encoding="utf-8") 

137 except OSError as exc: 

138 QMessageBox.critical(self.window, tr("XDTS書き出し"), str(exc)) 

139 return 

140 QMessageBox.information( 

141 self.window, tr("XDTS書き出し"), tr("タイムシートを書き出しました。\n\n{path}").format(path=path) 

142 ) 

143 

144 def sheet_tracks(self): 

145 """``(layer_names, tracks, duration)`` for a time sheet. 

146 

147 Each track holds one instruction per frame: a セル番号, or 

148 ``SYMBOL_HYPHEN`` / ``SYMBOL_NULL_CELL``. 

149 """ 

150 duration = self.window._sheet_duration() 

151 tracks = [] 

152 names = [] 

153 layer_count = len(self.window.canvas.frames[0].layers) 

154 for layer_index in range(layer_count): 

155 names.append(self.window.canvas.frames[0].layers[layer_index].name) 

156 values = [] 

157 for column in range(duration): 

158 kind, key_column, _exposure = TimelineWidget.timeline_span_at( 

159 self.window.canvas.frames, layer_index, column 

160 ) 

161 if kind == "content": 

162 if key_column is None: 

163 log.warning( 

164 "timeline reported content without a key frame: layer=%s frame=%s", 

165 layer_index, 

166 column, 

167 ) 

168 value = timesheet_file.NULL_CELL 

169 elif column == key_column: 

170 number = self.window.canvas.frames[key_column].layers[ 

171 layer_index 

172 ].sequence_number 

173 value = str(number) if number is not None else timesheet_file.NULL_CELL 

174 else: 

175 value = timesheet_file.HYPHEN 

176 elif kind == "blank": 176 ↛ 182line 176 didn't jump to line 182 because the condition on line 176 was always true

177 value = ( 

178 timesheet_file.NULL_CELL 

179 if column == key_column else timesheet_file.HYPHEN 

180 ) 

181 else: 

182 value = timesheet_file.NULL_CELL 

183 values.append(value) 

184 tracks.append(values) 

185 return names, tracks, duration 

186 

187 def xdts_text(self, sheet_name="PaintMaskAnimator"): 

188 """The current timeline as XDTS v5 text (header line + JSON).""" 

189 return self.sheet_text("xdts", sheet_name) 

190 

191 def sheet_text(self, fmt, sheet_name, **header): 

192 """The current timeline as an XDTS or TDTS time sheet.""" 

193 names, tracks, duration = self.sheet_tracks() 

194 return timesheet_file.sheet_text(fmt, sheet_name, names, tracks, duration, **header) 

195 

196 def cut_folder_cels(self): 

197 """Distinct drawings to write: one per (layer, セル番号), first seen wins. 

198 

199 Returns ``(cels, images, skipped)`` where ``images`` maps a cel to its 

200 canvas-sized image and ``skipped`` counts drawn keys that have no 

201 セル番号 (the XDTS could not reference them either). 

202 """ 

203 invalid = cut_folder.INVALID_NAME_CHARS + "/" 

204 frames = self.window.canvas.frames 

205 cels = [] 

206 images = {} 

207 skipped = 0 

208 if not frames: 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true

209 return cels, images, skipped 

210 for layer_index, template in enumerate(frames[0].layers): 

211 name = "".join( 

212 "_" if character in invalid else character 

213 for character in (template.name.strip() or default_layer_name(layer_index)) 

214 ).strip(" .") or default_layer_name(layer_index) 

215 seen = set() 

216 for frame in frames: 

217 if layer_index >= len(frame.layers): 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 continue 

219 layer = frame.layers[layer_index] 

220 if not layer.has_content or layer.sequence_only: 

221 continue 

222 if layer.sequence_number is None: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true

223 skipped += 1 

224 continue 

225 number = int(layer.sequence_number) 

226 if number in seen: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 continue 

228 seen.add(number) 

229 cel = cut_folder.CelSource(layer_index, name, number) 

230 cels.append(cel) 

231 images[cel] = layer.image 

232 return cels, images, skipped 

233 

234 def cut_folder(self): 

235 from .cut_folder_dialog import CutFolderExportDialog 

236 

237 cels, images, skipped = self.cut_folder_cels() 

238 project = self.window.current_project_path 

239 hint = cut_folder.guess_cut_number(Path(project).stem) if project else "" 

240 dialog = CutFolderExportDialog(cels, cut_hint=hint, parent=self.window) 

241 if dialog.exec() != QDialog.DialogCode.Accepted: 

242 return 

243 dialog.remember() 

244 plan = dialog.plan() 

245 parent = Path(dialog.destination.text().strip()) 

246 self.write_cut_folder(plan, parent, images, dialog.current_layout().image_format, skipped) 

247 

248 def write_cut_folder(self, plan, parent, images, image_format, skipped=0): 

249 target = parent / plan.folder_name 

250 try: 

251 if target.exists() and not target.is_dir(): 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 raise OperationError(tr("同じ名前のファイルがあるため、フォルダーを作成できません。")) 

253 if target.exists() and any(target.iterdir()): 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 answer = QMessageBox.question( 

255 self.window, 

256 tr("同名フォルダー"), 

257 tr("「{name}」には既存のファイルがあります。\n同じ名前のファイルは上書きします。書き出しますか?").format(name=plan.folder_name), 

258 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, 

259 QMessageBox.StandardButton.No, 

260 ) 

261 if answer != QMessageBox.StandardButton.Yes: 

262 return False 

263 target.mkdir(parents=True, exist_ok=True) 

264 for folder in plan.folders: 

265 (target / folder).mkdir(parents=True, exist_ok=True) 

266 except (OSError, OperationError) as exc: 

267 QMessageBox.critical( 

268 self.window, 

269 tr("カットフォルダー書き出し"), 

270 tr("書き出しフォルダーを作成できません。\n\n{exc}").format(exc=exc), 

271 ) 

272 return False 

273 

274 progress = create_counter( 

275 self.window, 

276 tr("カットフォルダー書き出し"), 

277 len(plan.files), 

278 tr("「{name}」へ書き出しています").format(name=plan.folder_name), 

279 ) 

280 try: 

281 for number, (relative, cel) in enumerate(plan.files, 1): 

282 update_counter( 

283 progress, number - 1, len(plan.files), 

284 tr("{number}枚目を書き出しています").format(number=number), 

285 ) 

286 image = images[cel].copy( 

287 OUTSIDE_MARGIN, 

288 OUTSIDE_MARGIN, 

289 constants.CANVAS_WIDTH, 

290 constants.CANVAS_HEIGHT, 

291 ) 

292 output_path = target / relative 

293 if image_format == "tga": 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true

294 saved = self.window.save_tga_image(image, output_path) 

295 else: 

296 saved = image.save(str(output_path), "PNG") 

297 if not saved: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true

298 raise OSError(tr("{name}を保存できませんでした。").format(name=output_path.name)) 

299 sheet_name = Path(plan.timesheet_path).stem 

300 (target / plan.timesheet_path).write_text( 

301 self.sheet_text(plan.timesheet_format, sheet_name, **plan.sheet_header), 

302 encoding="utf-8", 

303 ) 

304 # メモはタイムシートの隣へ出す(Issue #25)。空なら作らない。 

305 memo = str(getattr(self.window, "project_memo", "") or "").strip() 

306 if memo: 

307 ( 

308 target / Path(plan.timesheet_path).parent / MEMO_FILE_NAME 

309 ).write_text(memo + chr(10), encoding="utf-8") 

310 except _OPERATION_ERRORS as exc: 

311 log.error("cut folder export failed: %s", exc, exc_info=True) 

312 QMessageBox.critical( 

313 self.window, 

314 tr("カットフォルダー書き出し"), 

315 tr("書き出し中にエラーが発生しました。\n\n{exc}").format(exc=exc), 

316 ) 

317 return False 

318 finally: 

319 close_counter(progress) 

320 

321 message = tr("「{name}」へセル{count}枚とタイムシートを書き出しました。").format( 

322 name=plan.folder_name, count=len(plan.files) 

323 ) 

324 if skipped: 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true

325 message += "\n" + tr("セル番号のないキー{count}枚は書き出していません。").format(count=skipped) 

326 self.window.statusBar().showMessage(message.split("\n")[0], 4000) 

327 QMessageBox.information( 

328 self.window, tr("カットフォルダー書き出し"), f"{message}\n\n{target}" 

329 ) 

330 return True 

331 

332 def psd_dialog(self): 

333 if PSDImage is None or PILImage is None: 

334 QMessageBox.warning( 

335 self.window, 

336 tr("PSD書き出し"), 

337 tr("PSDの書き出しには psd-tools と Pillow が必要です。\n" 

338 "requirements.txtをインストールしてください。"), 

339 ) 

340 return 

341 path, _ = QFileDialog.getSaveFileName( 

342 self.window, 

343 tr("PSDを書き出す"), 

344 "PaintMaskAnimator.psd", 

345 "Photoshop Document (*.psd)", 

346 ) 

347 if not path: 

348 return 

349 if not path.lower().endswith(".psd"): 

350 path += ".psd" 

351 try: 

352 psd = PSDImage.new( 

353 "RGB", 

354 (int(constants.CANVAS_WIDTH), int(constants.CANVAS_HEIGHT)), 

355 color=(255, 255, 255), 

356 ) 

357 layer_count = len(self.window.canvas.frames[0].layers) 

358 exported_keys = 0 

359 for layer_index in range(layer_count): 

360 template = self.window.canvas.frames[0].layers[layer_index] 

361 folder_name = str(template.name or default_layer_name(layer_index)) 

362 group = psd.create_group( 

363 name=folder_name, 

364 opacity=max(0, min(255, int(round(template.opacity * 255)))), 

365 ) 

366 group.visible = bool(template.visible) 

367 key_number = 0 

368 for frame in self.window.canvas.frames: 

369 layer = frame.layers[layer_index] 

370 if not layer.has_content or layer.sequence_only: 

371 continue 

372 key_number += 1 

373 source = layer.image.copy( 

374 OUTSIDE_MARGIN, 

375 OUTSIDE_MARGIN, 

376 constants.CANVAS_WIDTH, 

377 constants.CANVAS_HEIGHT, 

378 ) 

379 pil_image = PaintCanvas._qimage_to_pil_rgba(source) 

380 if pil_image is None: 

381 raise OperationError(tr("PSD書き出しに必要な画像変換を利用できません。")) 

382 pixel_layer = psd.create_pixel_layer( 

383 pil_image, 

384 name=f"{folder_name}{key_number:04d}", 

385 ) 

386 group.append(pixel_layer) 

387 exported_keys += 1 

388 if exported_keys == 0: 

389 raise OperationError(tr("書き出せるキーフレームがありません。")) 

390 psd.save(path) 

391 except _OPERATION_ERRORS as exc: 

392 log.error("PSD export failed: %s", exc, exc_info=True) 

393 QMessageBox.critical( 

394 self.window, tr("PSD書き出し"), tr("PSDを書き出せませんでした。\n\n{exc}").format(exc=exc) 

395 ) 

396 return 

397 QMessageBox.information( 

398 self.window, 

399 tr("PSD書き出し"), 

400 tr("{keys}個のキーフレームを書き出しました。\n\n{path}").format(keys=exported_keys, path=path), 

401 ) 

402 

403 def key_sequence(self, image_format): 

404 invalid = '<>:"/\\|?*' 

405 safe_layer_names = [] 

406 used_names = set() 

407 for layer_index, layer in enumerate(self.window.canvas.layers): 

408 base_name = "".join( 

409 "_" if character in invalid else character 

410 for character in (layer.name.strip() or default_layer_name(layer_index)) 

411 ).strip(" .") or default_layer_name(layer_index) 

412 safe_name = base_name 

413 suffix = 2 

414 while safe_name.casefold() in used_names: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true

415 safe_name = f"{base_name}_{suffix}" 

416 suffix += 1 

417 used_names.add(safe_name.casefold()) 

418 safe_layer_names.append(safe_name) 

419 default_folder_name = f"PaintMaskAnimator_{image_format}_CSV" 

420 

421 # 最初のダイアログで保存場所と親フォルダー名を同時に指定する。 

422 folder_dialog = QFileDialog( 

423 self.window, 

424 tr("連番{format}+CSVの書き出しフォルダー").format(format=image_format), 

425 ) 

426 folder_dialog.setOption( 

427 QFileDialog.Option.DontUseNativeDialog, 

428 True, 

429 ) 

430 folder_dialog.setFileMode(QFileDialog.FileMode.AnyFile) 

431 folder_dialog.setAcceptMode( 

432 QFileDialog.AcceptMode.AcceptSave 

433 ) 

434 folder_dialog.setLabelText( 

435 QFileDialog.DialogLabel.FileName, 

436 tr("フォルダー名:"), 

437 ) 

438 folder_dialog.setLabelText( 

439 QFileDialog.DialogLabel.Accept, 

440 tr("この名前で作成"), 

441 ) 

442 folder_dialog.selectFile(default_folder_name) 

443 

444 if folder_dialog.exec() != QDialog.DialogCode.Accepted: 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 return 

446 selected = folder_dialog.selectedFiles() 

447 if not selected: 447 ↛ 448line 447 didn't jump to line 448 because the condition on line 447 was never true

448 return 

449 

450 destination = Path(selected[0]) 

451 folder_name = destination.name.strip() 

452 folder_name = "".join( 

453 "_" if character in invalid else character 

454 for character in folder_name 

455 ).strip(" .") 

456 if not folder_name or folder_name in (".", ".."): 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true

457 QMessageBox.warning( 

458 self.window, 

459 tr("フォルダー名"), 

460 tr("使用できるフォルダー名を指定してください。"), 

461 ) 

462 return 

463 destination = destination.parent / folder_name 

464 

465 try: 

466 if destination.exists() and not destination.is_dir(): 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true

467 QMessageBox.warning( 

468 self.window, 

469 tr("書き出し先"), 

470 tr("同じ名前のファイルが存在するため、" 

471 "フォルダーを作成できません。"), 

472 ) 

473 return 

474 if destination.exists() and any(destination.iterdir()): 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true

475 answer = QMessageBox.question( 

476 self.window, 

477 tr("同名フォルダー"), 

478 tr("「{name}」には既存のファイルがあります。\nこのフォルダーへ書き出しますか?").format(name=folder_name), 

479 QMessageBox.StandardButton.Yes 

480 | QMessageBox.StandardButton.No, 

481 QMessageBox.StandardButton.No, 

482 ) 

483 if answer != QMessageBox.StandardButton.Yes: 

484 return 

485 destination.mkdir(parents=True, exist_ok=True) 

486 for safe_layer_name in safe_layer_names: 

487 (destination / safe_layer_name).mkdir( 

488 parents=True, 

489 exist_ok=True, 

490 ) 

491 except OSError as exc: 

492 QMessageBox.critical( 

493 self.window, 

494 tr("フォルダー作成エラー"), 

495 tr("書き出しフォルダーを作成できません。\n\n{exc}").format(exc=exc), 

496 ) 

497 return 

498 

499 keys = [] 

500 for layer_index, safe_layer_name in enumerate(safe_layer_names): 

501 for frame_index, frame in enumerate(self.window.canvas.frames): 

502 if ( 502 ↛ 501line 502 didn't jump to line 501 because the condition on line 502 was always true

503 layer_index < len(frame.layers) 

504 and frame.layers[layer_index].has_content 

505 ): 

506 keys.append(( 

507 layer_index, 

508 safe_layer_name, 

509 frame_index, 

510 frame.layers[layer_index], 

511 )) 

512 

513 if not keys: 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true

514 QMessageBox.warning( 

515 self.window, 

516 tr("連番書き出し"), 

517 tr("書き出せるキーフレームがありません。"), 

518 ) 

519 return 

520 

521 timing_rows = [] 

522 progress = create_counter( 

523 self.window, 

524 tr("連番{format}書き出し").format(format=image_format), 

525 len(keys), 

526 tr("「{name}」へ書き出しています").format(name=folder_name), 

527 ) 

528 try: 

529 layer_numbers = {} 

530 for number, ( 

531 layer_index, 

532 safe_layer_name, 

533 frame_index, 

534 layer, 

535 ) in enumerate(keys, 1): 

536 update_counter( 

537 progress, 

538 number - 1, 

539 len(keys), 

540 tr("{number}枚目を書き出しています").format(number=number), 

541 ) 

542 image = layer.image.copy( 

543 OUTSIDE_MARGIN, 

544 OUTSIDE_MARGIN, 

545 constants.CANVAS_WIDTH, 

546 constants.CANVAS_HEIGHT, 

547 ) 

548 layer_number = layer_numbers.get(layer_index, 0) + 1 

549 layer_numbers[layer_index] = layer_number 

550 filename = f"{safe_layer_name}{layer_number:04d}" 

551 image_destination = destination / safe_layer_name 

552 if image_format == "PNG": 552 ↛ 561line 552 didn't jump to line 561 because the condition on line 552 was always true

553 output_path = image_destination / ( 

554 filename + ".png" 

555 ) 

556 if not image.save(str(output_path), "PNG"): 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true

557 raise OSError( 

558 tr("{name}を保存できませんでした。").format(name=output_path.name) 

559 ) 

560 else: 

561 output_path = image_destination / ( 

562 filename + ".tga" 

563 ) 

564 self.window.save_tga_image(image, output_path) 

565 

566 timing_rows.append([ 

567 filename, 

568 frame_index + 1, 

569 frame_index + max(1, layer.exposure), 

570 max(1, layer.exposure), 

571 ]) 

572 update_counter( 

573 progress, 

574 number, 

575 len(keys), 

576 tr("{number}枚目の書き出しが完了しました").format(number=number), 

577 ) 

578 

579 csv_path = destination / "TS.csv" 

580 with csv_path.open( 

581 "w", newline="", encoding="utf-8-sig" 

582 ) as file: 

583 writer = csv.writer(file) 

584 writer.writerow([ 

585 "name", 

586 "start_frame", 

587 "end_frame", 

588 "duration", 

589 ]) 

590 writer.writerows(timing_rows) 

591 except (_OPERATION_ERRORS + (csv.Error,)) as exc: 

592 log.error("sequence/CSV export failed: %s", exc, exc_info=True) 

593 QMessageBox.critical( 

594 self.window, 

595 tr("連番書き出しエラー"), 

596 tr("書き出し中にエラーが発生しました。\n\n{exc}").format(exc=exc), 

597 ) 

598 return 

599 finally: 

600 close_counter(progress) 

601 

602 self.window.statusBar().showMessage( 

603 tr("「{name}」へ{len}枚とTS.csvを書き出しました。").format(name=folder_name, len=len(keys)), 

604 4000, 

605 ) 

606 QMessageBox.information( 

607 self.window, 

608 tr("連番書き出し完了"), 

609 tr("次の構成で書き出しました。\n\n{destination}\n├─ 各レイヤー名のフォルダー\n│ └─ レイヤー名0001...\n└─ TS.csv").format(destination=destination), 

610 ) 

611 

612 def mp4(self): 

613 app_file = Path(sys.executable) if getattr(sys, "frozen", False) else Path(__file__) 

614 bundled_ffmpeg = app_file.with_name("ffmpeg.exe") 

615 ff = str(bundled_ffmpeg) if bundled_ffmpeg.exists() else shutil.which("ffmpeg") 

616 if not ff:QMessageBox.warning(self.window,'FFmpeg',tr('ffmpegが必要です。'));return 

617 path,_=QFileDialog.getSaveFileName(self.window,tr('MP4書き出し'),'animation.mp4','MP4 (*.mp4)') 

618 if not path:return 

619 if not path.lower().endswith('.mp4'):path+='.mp4' 

620 with tempfile.TemporaryDirectory() as td: 

621 total = sum(max(1, int(frame.duration)) for frame in self.window.canvas.frames) 

622 progress = create_counter( 

623 self.window, 

624 tr("MP4書き出し"), 

625 total, 

626 tr("動画用フレームを準備しています"), 

627 ) 

628 output_index = 0 

629 # 頭の数コマだけカット名を重ねる(Issue #26)。コマは足さないので 

630 # タイミングは変わらない。 

631 cut_name = movie_slate.cut_name_for( 

632 self.window.current_project_path 

633 ) 

634 for frame_index, frame in enumerate(self.window.canvas.frames): 

635 image = self.window.crop_image(frame_index) 

636 for _ in range(max(1, int(frame.duration))): 

637 output_index += 1 

638 written = ( 

639 movie_slate.draw_cut_name(image, cut_name) 

640 if output_index <= movie_slate.LEADING_FRAMES 

641 else image 

642 ) 

643 update_counter( 

644 progress, 

645 output_index - 1, 

646 total, 

647 tr("フレーム {index} / {total} を準備しています").format(index=output_index, total=total), 

648 ) 

649 written.save( 

650 str(Path(td) / f"f_{output_index:06}.png"), 

651 ) 

652 close_counter(progress) 

653 encoding = create_counter( 

654 self.window, 

655 tr("MP4書き出し"), 

656 1, 

657 tr("FFmpegで動画へ変換しています"), 

658 ) 

659 QApplication.processEvents() 

660 r=subprocess.run([ff,'-y','-framerate',str(self.window.timeline.fps.value()),'-i',str(Path(td)/'f_%06d.png'),'-c:v','libx264','-pix_fmt','yuv420p',path],capture_output=True,text=True) 

661 close_counter(encoding) 

662 if r.returncode:QMessageBox.critical(self.window,tr('MP4エラー'),r.stderr[-1500:]) 

663 

664 

665 # ------------------------------------------------------------------ 

666 # 影素材の書き出し(Issue #21) 

667 # ------------------------------------------------------------------ 

668 def shadow_folder_names(self): 

669 """色が1つ以上入っている使用色フォルダーの名前。""" 

670 palette = self.window.palette 

671 return [ 

672 name for name in palette.category_order 

673 if palette._category_members(name) 

674 ] 

675 

676 def shadow_materials(self, folder_name): 

677 """``folder_name`` の色を影として、キーごとの3素材を作る。 

678 

679 :returns: ``[(レイヤー名, 絵番号, {種類: QImage}), ...]``。 

680 """ 

681 palette = self.window.palette 

682 shadow_rgbs = palette._category_members(folder_name) 

683 results = [] 

684 if not shadow_rgbs: 

685 return results 

686 frames = self.window.canvas.frames 

687 if not frames: 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true

688 return results 

689 for layer_index, template in enumerate(frames[0].layers): 

690 layer_name = str(template.name or default_layer_name(layer_index)) 

691 seen = set() 

692 for frame_index, frame in enumerate(frames): 

693 if layer_index >= len(frame.layers): 693 ↛ 694line 693 didn't jump to line 694 because the condition on line 693 was never true

694 continue 

695 layer = frame.layers[layer_index] 

696 if not layer.has_content or layer.sequence_only: 696 ↛ 697line 696 didn't jump to line 697 because the condition on line 696 was never true

697 continue 

698 # 同じ絵を何度も書き出さない。番号が無ければコマ番号を使う。 

699 number = ( 

700 int(layer.sequence_number) 

701 if layer.sequence_number is not None 

702 else frame_index + 1 

703 ) 

704 if number in seen: 704 ↛ 705line 704 didn't jump to line 705 because the condition on line 704 was never true

705 continue 

706 seen.add(number) 

707 pixels = imaging.qimage_rgba_array(layer.image) 

708 images = { 

709 kind: imaging.rgba_array_to_qimage(array) 

710 for kind, array in shadow_export.materials( 

711 pixels, shadow_rgbs 

712 ).items() 

713 } 

714 results.append((layer_name, number, images)) 

715 return results 

716 

717 def shadow_sequence(self): 

718 """使用色フォルダーを選び、影素材の連番を書き出す。""" 

719 folders = self.shadow_folder_names() 

720 if not folders: 

721 QMessageBox.warning( 

722 self.window, 

723 tr("影素材の書き出し"), 

724 tr("色の入った使用色フォルダーがありません。" 

725 "影色をフォルダーにまとめてから実行してください。"), 

726 ) 

727 return False 

728 folder_name, accepted = QInputDialog.getItem( 

729 self.window, 

730 tr("影素材の書き出し"), 

731 tr("影色をまとめたフォルダー:"), 

732 folders, 

733 0, 

734 False, 

735 ) 

736 if not accepted or not folder_name: 

737 return False 

738 destination = QFileDialog.getExistingDirectory( 

739 self.window, tr("書き出し先のフォルダー") 

740 ) 

741 if not destination: 

742 return False 

743 return self.write_shadow_sequence(folder_name, Path(destination)) 

744 

745 def write_shadow_sequence(self, folder_name, destination): 

746 """``destination`` の下へ、種類ごとのフォルダーを作って書き出す。""" 

747 materials = self.shadow_materials(folder_name) 

748 if not materials: 

749 self.window.statusBar().showMessage( 

750 tr("書き出せるセルがありません。"), 2800 

751 ) 

752 return False 

753 

754 progress = create_counter( 

755 self.window, 

756 tr("影素材の書き出し"), 

757 len(materials), 

758 tr("「{name}」の影素材を書き出しています").format(name=folder_name), 

759 ) 

760 written = 0 

761 try: 

762 for index, (layer_name, number, images) in enumerate(materials): 

763 update_counter( 

764 progress, index, len(materials), 

765 tr("{number}枚目を書き出しています").format(number=index + 1), 

766 ) 

767 for kind, image in images.items(): 

768 target = destination / kind / layer_name 

769 target.mkdir(parents=True, exist_ok=True) 

770 cropped = image.copy( 

771 OUTSIDE_MARGIN, 

772 OUTSIDE_MARGIN, 

773 constants.CANVAS_WIDTH, 

774 constants.CANVAS_HEIGHT, 

775 ) 

776 path = target / f"{layer_name}{number:04d}.png" 

777 if not cropped.save(str(path), "PNG"): 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true

778 raise OSError( 

779 tr("{name}を保存できませんでした。").format( 

780 name=path.name 

781 ) 

782 ) 

783 written += 1 

784 except _OPERATION_ERRORS as exc: 

785 log.error("shadow export failed: %s", exc, exc_info=True) 

786 QMessageBox.critical( 

787 self.window, 

788 tr("影素材の書き出し"), 

789 tr("書き出し中にエラーが発生しました。" + chr(10) + chr(10) + "{exc}").format(exc=exc), 

790 ) 

791 return False 

792 finally: 

793 close_counter(progress) 

794 

795 self.window.statusBar().showMessage( 

796 tr("影素材を{count}枚書き出しました:{destination}").format( 

797 count=written, destination=destination 

798 ), 

799 4000, 

800 ) 

801 return True