Coverage for paintmaskanimator/main_window_time_remap.py: 64%
419 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"""Time remap (After Effects timesheets); owned as ``window.time_remap``.
3Parses After Effects time-remap text, rebuilds the per-layer source bank, and
4applies the remapped exposure states to the active layer.
6A collaborator rather than a mixin -- see ``main_window_export.py`` for why.
7"""
8from typing import TYPE_CHECKING, Any
10import json
11import math
12import re
13from pathlib import Path
14from PySide6.QtWidgets import QApplication, QDialog, QMessageBox
15from . import timesheet_file
16from .i18n import tr
17from .main_window_import import ImportController
18from .errors import OPERATION_ERRORS as _OPERATION_ERRORS, OperationError
19from .models import Layer
20from .utils import blank_image
21from .widgets import (
22 TimeRemapPasteDialog,
23 default_layer_bindings,
24 sheet_columns_from_parsed,
25)
26from .logging_setup import get_logger
28if TYPE_CHECKING:
29 from .main_window import MainWindow
31log = get_logger(__name__)
34class TimeRemapController:
35 """Owned by ``MainWindow`` as ``window.time_remap``.
37 A collaborator rather than a mixin -- see main_window_export.py for why.
38 """
40 def __init__(self, window: "MainWindow"):
41 self.window = window
43 @staticmethod
44 def _parse_toei_timesheet(raw_text):
45 text_value = str(raw_text or "").strip()
46 json_start = text_value.find("{")
47 if json_start < 0: 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true
48 raise OperationError(tr("JSONデータが見つかりません。"))
49 try:
50 payload = json.loads(text_value[json_start:])
51 except json.JSONDecodeError as exc:
52 raise OperationError(
53 tr("ToeiDigitalTimeSheetのJSONを解析できません。\n{exc}").format(exc=exc)
54 ) from exc
56 layers = payload.get("layers")
57 if not isinstance(layers, list) or not layers: 57 ↛ 60line 57 didn't jump to line 60 because the condition on line 57 was always true
58 raise OperationError(tr("layersデータが見つかりません。"))
60 selected_layer = None
61 for layer in layers:
62 frames = (
63 layer.get("frames")
64 if isinstance(layer, dict) else None
65 )
66 if isinstance(frames, list) and frames:
67 selected_layer = layer
68 break
69 if selected_layer is None:
70 raise OperationError(tr("framesデータが見つかりません。"))
72 parsed_entries = {}
73 for entry in selected_layer.get("frames", []):
74 if not isinstance(entry, dict):
75 continue
76 try:
77 frame = int(entry.get("frame")) # pyright: ignore[reportArgumentType] # None/str caught below
78 except (TypeError, ValueError):
79 continue
80 if frame < 0:
81 continue
83 values = []
84 data_items = entry.get("data", [])
85 if isinstance(data_items, list):
86 for data_item in data_items:
87 if not isinstance(data_item, dict):
88 continue
89 item_values = data_item.get("values", [])
90 if isinstance(item_values, list):
91 values.extend(item_values)
92 elif item_values not in (None, ""):
93 values.append(item_values)
95 token = None
96 for value in values:
97 candidate = str(value).strip()
98 if candidate:
99 token = candidate
100 break
101 parsed_entries[frame] = token
103 if not parsed_entries:
104 raise OperationError(
105 tr("有効なToeiDigitalTimeSheetフレームがありません。")
106 )
108 start_frame = min(parsed_entries)
109 end_frame = max(parsed_entries)
110 current_state = None
111 states = []
112 blank_label_count = 0
114 for frame in range(start_frame, end_frame + 1):
115 if frame in parsed_entries:
116 token = parsed_entries[frame]
117 if token is None:
118 # 値なしセルは直前セルの状態を保持。
119 pass
120 elif re.fullmatch(r"[0-9]+", token):
121 current_state = max(1, int(token))
122 else:
123 # 中割トラックラベル/記号セルは空フレーム。
124 current_state = None
125 blank_label_count += 1
126 states.append(current_state)
128 return {
129 "format": "ToeiDigitalTimeSheet",
130 "fps": None,
131 "start_frame": start_frame,
132 "end_frame": end_frame,
133 "states": states,
134 "blank_label_count": blank_label_count,
135 }
137 @staticmethod
138 def _parse_after_effects_time_remap(raw_text):
139 text_value = str(raw_text or "").replace("\r", "")
140 fps_match = re.search(
141 r"Units\s+Per\s+Second\s+([0-9]+(?:\.[0-9]+)?)",
142 text_value,
143 re.IGNORECASE,
144 )
145 fps = float(fps_match.group(1)) if fps_match else 24.0
146 if fps <= 0.0: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 fps = 24.0
149 time_entries = []
150 opacity_entries = []
151 section = None
152 number_pattern = re.compile(
153 r"^\s*(-?\d+(?:\.\d+)?)\s+"
154 r"(-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)"
155 )
157 for line in text_value.splitlines():
158 lowered = line.strip().lower()
159 if lowered.startswith("time remap"):
160 section = "time"
161 continue
162 if (
163 lowered.startswith("transform")
164 and "opacity" in lowered
165 ):
166 section = "opacity"
167 continue
168 if lowered.startswith("end of keyframe data"):
169 section = None
170 continue
172 match = number_pattern.match(line)
173 if match is None or section is None:
174 continue
175 frame = int(round(float(match.group(1))))
176 value = float(match.group(2))
177 if frame < 0: 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 continue
179 if section == "time":
180 time_entries.append((frame, value))
181 else:
182 opacity_entries.append((frame, value))
184 if not time_entries:
185 raise OperationError(
186 tr("Time RemapのFrame/secondsデータが見つかりません。")
187 )
189 # 同じフレームが複数ある場合は、後から書かれた値を優先。
190 time_map = {}
191 for frame, value in time_entries:
192 time_map[int(frame)] = float(value)
193 opacity_map = {}
194 for frame, value in opacity_entries:
195 opacity_map[int(frame)] = float(value)
196 time_entries = sorted(time_map.items())
197 opacity_entries = sorted(opacity_map.items())
199 all_frames = [frame for frame, _value in time_entries]
200 all_frames.extend(
201 frame for frame, _value in opacity_entries
202 )
203 start_frame = max(0, min(all_frames))
204 end_frame = max(all_frames)
206 time_index = 0
207 current_seconds = float(time_entries[0][1])
208 opacity_index = 0
209 current_opacity = (
210 float(opacity_entries[0][1])
211 if opacity_entries else 100.0
212 )
213 states = []
215 for frame in range(start_frame, end_frame + 1):
216 while (
217 time_index + 1 < len(time_entries)
218 and time_entries[time_index + 1][0] <= frame
219 ):
220 time_index += 1
221 current_seconds = float(
222 time_entries[time_index][1]
223 )
224 while (
225 opacity_entries
226 and opacity_index + 1 < len(opacity_entries)
227 and opacity_entries[opacity_index + 1][0] <= frame
228 ):
229 opacity_index += 1
230 current_opacity = float(
231 opacity_entries[opacity_index][1]
232 )
234 if current_seconds < 0.0 or current_opacity <= 0.0:
235 states.append(None)
236 else:
237 # AEの0秒は連番1番、1/fps秒は連番2番。
238 source_index = int(
239 math.floor(current_seconds * fps + 0.5)
240 ) + 1
241 states.append(max(1, source_index))
243 return {
244 "format": "Adobe After Effects",
245 "fps": fps,
246 "start_frame": start_frame,
247 "end_frame": end_frame,
248 "states": states,
249 "blank_label_count": 0,
250 }
252 @classmethod
253 def parse_text(cls, raw_text):
254 text_value = (
255 str(raw_text or "")
256 .lstrip("\ufeff")
257 .strip()
258 )
259 if not text_value: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 raise OperationError(tr("貼り付けデータが空です。"))
262 lowered = text_value.lower()
263 if text_value.startswith((timesheet_file.XDTS_SIGNATURE, timesheet_file.TDTS_SIGNATURE)):
264 return ImportController._parse_xdts_timesheet(text_value)
265 if (
266 "toeidigitaltimesheet copy data" in lowered
267 or (
268 text_value.startswith("{")
269 and '"layers"' in text_value
270 and '"frames"' in text_value
271 )
272 ):
273 return cls._parse_toei_timesheet(text_value)
275 if (
276 "adobe after effects" in lowered
277 or "time remap" in lowered
278 ):
279 return cls._parse_after_effects_time_remap(text_value)
281 raise OperationError(
282 tr("Adobe After Effects、ToeiDigitalTimeSheet、XDTS形式を"
283 "判別できませんでした。")
284 )
286 def _time_remap_source_bank(self, layer_index):
287 stored_bank = getattr(
288 self.window.canvas,
289 "_sequence_source_bank",
290 [],
291 )
292 stored_index = int(
293 getattr(
294 self.window.canvas,
295 "_sequence_source_bank_layer_index",
296 -1,
297 )
298 )
299 stored_name = str(
300 getattr(
301 self.window.canvas,
302 "_sequence_source_bank_layer_name",
303 "",
304 )
305 )
307 active_name = ""
308 if ( 308 ↛ 325line 308 didn't jump to line 325 because the condition on line 308 was always true
309 self.window.canvas.frames
310 and 0 <= self.window.canvas.current_frame
311 < len(self.window.canvas.frames)
312 and 0 <= layer_index
313 < len(
314 self.window.canvas.frames[
315 self.window.canvas.current_frame
316 ].layers
317 )
318 ):
319 active_name = str(
320 self.window.canvas.frames[
321 self.window.canvas.current_frame
322 ].layers[layer_index].name
323 )
325 if stored_bank and (
326 stored_index == layer_index
327 or (
328 stored_name
329 and stored_name == active_name
330 )
331 ):
332 return [image.copy() for image in stored_bank]
334 numbered_bank = {}
335 for frame in self.window.canvas.frames:
336 if layer_index >= len(frame.layers): 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
337 continue
338 layer = frame.layers[layer_index]
339 number = layer.sequence_number
340 if ( 340 ↛ 335line 340 didn't jump to line 335 because the condition on line 340 was always true
341 layer.has_content
342 and number is not None
343 and int(number) >= 1
344 and int(number) not in numbered_bank
345 ):
346 numbered_bank[int(number)] = layer.image.copy()
347 # シートに並べていない絵(連番保管セル)も、同じ絵番号で使えるようにする。
348 # これを数えないと番号に抜けができ、下の「左から数える」方式へ落ちて
349 # 絵と番号がずれていた。
350 for (archive_layer, number), layer in getattr(
351 self.window.canvas, "_sequence_archive", {}
352 ).items():
353 if ( 353 ↛ 350line 353 didn't jump to line 350 because the condition on line 353 was always true
354 int(archive_layer) == layer_index
355 and int(number) >= 1
356 and int(number) not in numbered_bank
357 and layer.has_content
358 ):
359 numbered_bank[int(number)] = layer.image.copy()
360 if numbered_bank: 360 ↛ 370line 360 didn't jump to line 370 because the condition on line 360 was always true
361 # 本当に抜けている番号は None にしておき、参照されたら
362 # 「存在しない絵番号」として止める(勝手に詰めない)。
363 return [
364 numbered_bank.get(number)
365 for number in range(1, max(numbered_bank) + 1)
366 ]
368 # 番号情報のない旧プロジェクトでは、選択レイヤー内の
369 # 内容キーを左から連番ソースとして採用する。
370 bank = []
371 for frame in self.window.canvas.frames:
372 if layer_index >= len(frame.layers):
373 continue
374 layer = frame.layers[layer_index]
375 if layer.has_content and not layer.image.isNull():
376 bank.append(layer.image.copy())
377 return bank
379 @staticmethod
380 def missing_numbers(source_bank, numbers):
381 """``source_bank`` に絵が無い番号(範囲外・抜け)を返す。"""
382 return [
383 number for number in numbers
384 if number < 1
385 or number > len(source_bank)
386 or source_bank[number - 1] is None
387 ]
389 def keep_unplaced_in_archive(self, layer_index, source_bank):
390 """シートで使わなかった絵番号を連番保管セルに残す(使った番号は外す)。"""
391 canvas = self.window.canvas
392 placed = {
393 int(frame.layers[layer_index].sequence_number)
394 for frame in canvas.frames
395 if layer_index < len(frame.layers)
396 and frame.layers[layer_index].has_content
397 and frame.layers[layer_index].sequence_number is not None
398 }
399 name = ""
400 if canvas.frames and layer_index < len(canvas.frames[0].layers): 400 ↛ 402line 400 didn't jump to line 402 because the condition on line 400 was always true
401 name = canvas.frames[0].layers[layer_index].name
402 archive = canvas._sequence_archive
403 for number, image in enumerate(source_bank, 1):
404 if image is None:
405 continue
406 key = (int(layer_index), number)
407 if number in placed:
408 archive.pop(key, None)
409 elif key not in archive: 409 ↛ 403line 409 didn't jump to line 403 because the condition on line 409 was always true
410 archive[key] = Layer(
411 name, image.copy(), has_content=True, exposure=1,
412 sequence_number=number,
413 )
414 self.window.timeline.sequence_archive = archive
416 def _apply_time_remap_states_to_layer(
417 self,
418 states,
419 start_frame,
420 end_frame,
421 layer_index,
422 source_bank,
423 current_frame,
424 ):
425 """検証済みのセル番号列を1レイヤーのシートへ展開する。"""
426 self.window.canvas._ensure_frame_count(end_frame + 1)
427 template = self.window.canvas.frames[
428 current_frame
429 ].layers[layer_index].clone()
431 # 対象範囲より前のキーの露出が入り込まないよう切る。
432 for prior in range(start_frame - 1, -1, -1): 432 ↛ 433line 432 didn't jump to line 433 because the loop on line 432 never started
433 prior_layer = self.window.canvas.frames[prior].layers[layer_index]
434 exposure = max(1, int(prior_layer.exposure))
435 if prior + exposure > start_frame:
436 prior_layer.exposure = max(1, start_frame - prior)
437 break
438 if prior_layer.has_content:
439 break
441 # 対象範囲をいったん明示空セルに戻す。
442 for frame_index in range(start_frame, end_frame + 1):
443 layer = self.window.canvas.frames[frame_index].layers[layer_index]
444 self.window.layers._copy_display_properties(template, layer)
445 layer.image = blank_image()
446 layer.has_content = False
447 layer.is_blank_key = False
448 layer.sequence_number = None
449 layer.cell_name = None
450 layer.exposure = 1
452 # 同じ絵番号/空フレームが連続する区間を露出へ圧縮。
453 run_start = start_frame
454 run_state: Any = states[0]
455 sentinel = object()
456 for offset in range(1, len(states) + 1):
457 next_state = states[offset] if offset < len(states) else sentinel
458 if offset < len(states) and next_state == run_state: 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true
459 continue
460 run_end = start_frame + offset - 1
461 target = self.window.canvas.frames[run_start].layers[layer_index]
462 self.window.layers._copy_display_properties(template, target)
463 target.exposure = max(1, run_end - run_start + 1)
464 if run_state is None: 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true
465 target.image = blank_image()
466 target.has_content = False
467 target.is_blank_key = True
468 target.sequence_number = None
469 target.cell_name = None
470 else:
471 target.image = source_bank[int(run_state) - 1].copy()
472 target.has_content = True
473 target.is_blank_key = False
474 target.sequence_number = int(run_state)
475 target.cell_name = None
476 if offset < len(states):
477 run_start = start_frame + offset
478 run_state = next_state
480 def apply_to_active_layer(self, parsed, confirm=True):
481 states = list(parsed.get("states", []))
482 start_frame = int(parsed.get("start_frame", 0))
483 end_frame = int(parsed.get("end_frame", -1))
484 if ( 484 ↛ 490line 484 didn't jump to line 490 because the condition on line 484 was never true
485 not states
486 or start_frame < 0
487 or end_frame < start_frame
488 or len(states) != end_frame - start_frame + 1
489 ):
490 raise OperationError(tr("解析したフレーム範囲が不正です。"))
492 if not self.window.canvas.frames: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true
493 raise OperationError(tr("タイムラインがありません。"))
494 layer_index = int(self.window.canvas.active_layer_index)
495 current_frame = max(
496 0,
497 min(
498 int(self.window.canvas.current_frame),
499 len(self.window.canvas.frames) - 1,
500 ),
501 )
502 if not ( 502 ↛ 506line 502 didn't jump to line 506 because the condition on line 502 was never true
503 0 <= layer_index
504 < len(self.window.canvas.frames[current_frame].layers)
505 ):
506 raise OperationError(tr("対象レイヤーを選択してください。"))
508 source_bank = self._time_remap_source_bank(layer_index)
509 if not source_bank: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 raise OperationError(
511 tr("選択レイヤーに連番画像がありません。\n"
512 "先に画像連番を読み込んでください。")
513 )
515 referenced = sorted({
516 int(state)
517 for state in states
518 if state is not None
519 })
520 missing = self.missing_numbers(source_bank, referenced)
521 if missing:
522 preview = ", ".join(
523 str(value) for value in missing[:12]
524 )
525 if len(missing) > 12: 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true
526 preview += "…"
527 raise OperationError(
528 tr("連番画像は{len}枚ですが、存在しない絵番号が参照されています。\n{preview}").format(len=len(source_bank), preview=preview)
529 )
531 format_name = str(
532 parsed.get("format", tr("タイムリマップ"))
533 )
534 blank_count = sum(
535 1 for state in states if state is None
536 )
537 label_blanks = int(
538 parsed.get("blank_label_count", 0)
539 )
540 message = (
541 tr("形式:{name}\n反映範囲:{value}~{value2}フレーム\n連番画像:{len}枚\n空フレーム:{count}フレーム").format(name=format_name, value=start_frame + 1, value2=end_frame + 1, len=len(source_bank), count=blank_count)
542 )
543 if label_blanks: 543 ↛ 544line 543 didn't jump to line 544 because the condition on line 543 was never true
544 message += (
545 tr("\n中割・記号ラベル:{blanks}セル").format(blanks=label_blanks)
546 )
547 message += (
548 "\n\n選択レイヤーの対象範囲を置き換えます。"
549 )
551 if confirm:
552 answer = QMessageBox.question(
553 self.window,
554 tr("タイムリマップを反映"),
555 message,
556 QMessageBox.StandardButton.Yes
557 | QMessageBox.StandardButton.No,
558 QMessageBox.StandardButton.Yes,
559 )
560 if answer != QMessageBox.StandardButton.Yes: 560 ↛ 561line 560 didn't jump to line 561 because the condition on line 560 was never true
561 return False
563 self.window.timeline_ops.set_mode("sheet")
564 self.window.canvas.push_doc_undo()
565 self._apply_time_remap_states_to_layer(
566 states,
567 start_frame,
568 end_frame,
569 layer_index,
570 source_bank,
571 current_frame,
572 )
573 self.keep_unplaced_in_archive(layer_index, source_bank)
575 self.window.canvas.current_frame = start_frame
576 self.window.canvas.active_layer_index = layer_index
577 self.window.canvas._onion_cache.clear()
578 self.window.canvas._color_filter_cache.clear()
579 self.window.canvas._color_index_cache.clear()
580 self.window.canvas._silhouette_cache.clear()
581 self.window._used_color_cache.clear()
582 self.window.canvas.changed.emit()
583 self.window.canvas.selectionChanged.emit()
584 self.window.canvas.update()
585 self.window.used_color.schedule_refresh()
587 self.window.statusBar().showMessage(
588 tr("{name}を{value}~{value2}フレームへ反映しました。").format(name=format_name, value=start_frame + 1, value2=end_frame + 1),
589 4200,
590 )
591 return True
593 def show_paste_dialog(self, file_path=None):
594 clipboard_text = QApplication.clipboard().text()
595 if file_path:
596 try:
597 clipboard_text = Path(file_path).read_text(
598 encoding="utf-8-sig"
599 )
600 except (OSError, UnicodeError) as exc:
601 QMessageBox.warning(
602 self.window,
603 tr("XDTS読み込み"),
604 tr("読み込めませんでした。\n\n{exc}").format(exc=exc),
605 )
606 return False
607 dialog = TimeRemapPasteDialog(
608 clipboard_text,
609 self.window,
610 parse_text=self.parse_text,
611 canvas=self.window.canvas,
612 )
613 if dialog.exec() != QDialog.DialogCode.Accepted:
614 return False
615 try:
616 parsed = dialog.parsed_result()
617 if parsed is None:
618 raise OperationError(tr("使用するタイムシート行がありません。"))
619 if (
620 str(parsed.get("format", "")).startswith("XDTS")
621 and parsed.get("sheet_columns")
622 ):
623 return self.window.importer.apply_xdts_layer_bindings(parsed)
624 return self.apply_to_active_layer(parsed)
625 except _OPERATION_ERRORS as exc:
626 log.warning("time-remap paste failed: %s", exc, exc_info=True)
627 QMessageBox.warning(
628 self.window,
629 tr("タイムリマップ貼り付け"),
630 tr("タイムラインへ反映できませんでした。\n\n{exc}").format(exc=exc),
631 )
632 return False
634 def paste_on_timeline(self):
635 """タイムラインで貼り付けたとき、クリップボードのタイムシートをそのまま反映する。
637 戻り値は、タイムシートとして扱った(反映した/エラーを知らせた)なら True、
638 タイムシートの文字列でなければ False(呼び出し側は通常の貼り付けをする)。
639 確認は出さない。Undo 1回で元に戻る。
640 """
641 text = QApplication.clipboard().text()
642 if not text.strip(): 642 ↛ 643line 642 didn't jump to line 643 because the condition on line 642 was never true
643 return False
644 try:
645 parsed = self.parse_text(text)
646 except _OPERATION_ERRORS:
647 return False
648 canvas = self.window.canvas
649 try:
650 columns = sheet_columns_from_parsed(parsed)
651 if not columns: 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true
652 raise OperationError(tr("使用するタイムシート行がありません。"))
653 if str(parsed.get("format", "")).startswith("XDTS") and parsed.get( 653 ↛ 656line 653 didn't jump to line 656 because the condition on line 653 was never true
654 "sheet_columns"
655 ):
656 frame_index = max(0, min(int(canvas.current_frame), len(canvas.frames) - 1))
657 layers = [
658 (index, str(layer.name))
659 for index, layer in enumerate(canvas.frames[frame_index].layers)
660 ]
661 bindings = default_layer_bindings(
662 columns, layers, int(canvas.active_layer_index)
663 )
664 if not bindings:
665 # 紐づけ先を決められないときは、ダイアログで選んでもらう。
666 self.show_paste_dialog()
667 return True
668 result = dict(parsed, layer_bindings=bindings)
669 self.window.importer.apply_xdts_layer_bindings(result, confirm=False)
670 return True
671 primary = next((column for column in columns if column.get("states")), None)
672 if primary is None: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true
673 raise OperationError(tr("使用するタイムシート行がありません。"))
674 states = list(primary.get("states", []))
675 start_frame = int(parsed.get("start_frame", 0))
676 result = dict(
677 parsed,
678 states=states,
679 start_frame=start_frame,
680 end_frame=start_frame + len(states) - 1,
681 blank_label_count=int(primary.get("blank_label_count", 0)),
682 )
683 self.apply_to_active_layer(result, confirm=False)
684 return True
685 except _OPERATION_ERRORS as exc:
686 log.warning("time-remap paste on timeline failed: %s", exc, exc_info=True)
687 QMessageBox.warning(
688 self.window,
689 tr("タイムリマップ貼り付け"),
690 tr("タイムラインへ反映できませんでした。\n\n{exc}").format(exc=exc),
691 )
692 return True
694 def open_dropped(self, path):
695 remap_path = Path(path)
696 if remap_path.suffix.lower() not in (".xdts", ".xtds"):
697 return False
698 return bool(self.show_paste_dialog(str(remap_path)))
701 # ------------------------------------------------------------------
702 # タイムラインからのコピー(Issue #23)
703 # ------------------------------------------------------------------
704 def timeline_states(self, layer_index=None):
705 """シートの並びを ``[絵番号 または None, ...]`` として読み出す。
707 ``None`` は空セル(○)と未使用セル。貼り付け(``parse_text``)が作る
708 ``states`` と同じ形なので、往復しても同じ並びになる。
709 """
710 canvas = self.window.canvas
711 if layer_index is None: 711 ↛ 713line 711 didn't jump to line 713 because the condition on line 711 was always true
712 layer_index = int(canvas.active_layer_index)
713 layer_index = int(layer_index)
714 states: list = []
715 for column in range(len(canvas.frames)):
716 if not (0 <= layer_index < len(canvas.frames[column].layers)): 716 ↛ 717line 716 didn't jump to line 717 because the condition on line 716 was never true
717 states.append(None)
718 continue
719 block = canvas.timeline_block_at(column, layer_index)
720 if block is None:
721 states.append(None)
722 continue
723 _kind, start, _exposure = block
724 layer = canvas.frames[start].layers[layer_index]
725 states.append(
726 int(layer.sequence_number)
727 if layer.has_content and layer.sequence_number is not None
728 else None
729 )
730 return states
732 def after_effects_text(self, states=None, fps=None):
733 """After Effects の Time Remap キーフレームデータを組み立てる。
735 絵番号 N は ``(N - 1) / fps`` 秒に置く(連番を1枚ずつ並べた素材の
736 先頭を 0 秒とする、貼り付け側と同じ数え方)。空セルは不透明度 0 の
737 キーで表し、絵が無いことを示す。
738 """
739 states = self.timeline_states() if states is None else list(states)
740 if fps is None:
741 fps = float(self.window.timeline.fps.value())
742 fps = float(fps) if float(fps) > 0 else 24.0
744 time_lines = []
745 opacity_lines = []
746 visible = None
747 for index, state in enumerate(states):
748 frame = index + 1
749 if state is None:
750 # 空セルは不透明度0で表す。見えていた直後だけキーを置く。
751 if visible is not False:
752 opacity_lines.append(f"\t{frame}\t0")
753 visible = False
754 continue
755 if visible is not True:
756 opacity_lines.append(f"\t{frame}\t100")
757 visible = True
758 time_lines.append(f"\t{frame}\t{(int(state) - 1) / fps:.6f}")
760 if not time_lines:
761 raise OperationError(
762 tr("書き出せるセルがありません。絵番号の付いたセルが要ります。")
763 )
765 return "\n".join([
766 "Adobe After Effects 8.0 Keyframe Data",
767 "",
768 f"\tUnits Per Second\t{fps:g}",
769 "\tSource Width\t0",
770 "\tSource Height\t0",
771 "\tSource Pixel Aspect Ratio\t1",
772 "\tComp Pixel Aspect Ratio\t1",
773 "",
774 "Time Remap",
775 "\tFrame\tseconds",
776 *time_lines,
777 "",
778 "Transform\tOpacity",
779 "\tFrame\tpercent",
780 *opacity_lines,
781 "",
782 "End of Keyframe Data",
783 "",
784 ])
786 def copy_from_timeline(self):
787 """いまのシートをタイムリマップとしてクリップボードへ写す。"""
788 try:
789 text = self.after_effects_text()
790 except OperationError as exc:
791 self.window.statusBar().showMessage(str(exc), 3000)
792 return False
793 clipboard = QApplication.clipboard()
794 if clipboard is None: 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true
795 return False
796 clipboard.setText(text)
797 self.window.statusBar().showMessage(
798 tr("タイムリマップをクリップボードへコピーしました。"), 3000
799 )
800 return True