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
« 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).
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.
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
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
39if TYPE_CHECKING:
40 from .main_window import MainWindow
42log = get_logger(__name__)
45class ExportController:
46 """Exports the current document; owned by ``MainWindow`` as ``window.export``."""
48 def __init__(self, window: "MainWindow"):
49 self.window = window
51 # -- 今の絵の書き出し(キャンバス下のバー) --------------------------------
53 def canvas_image(self):
54 """今表示しているコマの、キャンバス範囲の合成画像。"""
55 return self.window.crop_image(self.window.canvas.current_frame)
57 @staticmethod
58 def snapshot_path(folder):
59 """``folder`` に置く、年月日時分秒(14桁)の .png のパス。
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
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
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
95 def save_canvas_snapshot(self, choose_folder=False):
96 """今の絵を保存先フォルダーへ年月日時分秒の名前で書き出す。
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
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 )
144 def sheet_tracks(self):
145 """``(layer_names, tracks, duration)`` for a time sheet.
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
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)
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)
196 def cut_folder_cels(self):
197 """Distinct drawings to write: one per (layer, セル番号), first seen wins.
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
234 def cut_folder(self):
235 from .cut_folder_dialog import CutFolderExportDialog
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)
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
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)
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
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 )
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"
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)
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
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
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
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 ))
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
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)
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 )
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)
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 )
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:])
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 ]
676 def shadow_materials(self, folder_name):
677 """``folder_name`` の色を影として、キーごとの3素材を作る。
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
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))
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
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)
795 self.window.statusBar().showMessage(
796 tr("影素材を{count}枚書き出しました:{destination}").format(
797 count=written, destination=destination
798 ),
799 4000,
800 )
801 return True