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

1"""Time remap (After Effects timesheets); owned as ``window.time_remap``. 

2 

3Parses After Effects time-remap text, rebuilds the per-layer source bank, and 

4applies the remapped exposure states to the active layer. 

5 

6A collaborator rather than a mixin -- see ``main_window_export.py`` for why. 

7""" 

8from typing import TYPE_CHECKING, Any 

9 

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 

27 

28if TYPE_CHECKING: 

29 from .main_window import MainWindow 

30 

31log = get_logger(__name__) 

32 

33 

34class TimeRemapController: 

35 """Owned by ``MainWindow`` as ``window.time_remap``. 

36 

37 A collaborator rather than a mixin -- see main_window_export.py for why. 

38 """ 

39 

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

41 self.window = window 

42 

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 

55 

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データが見つかりません。")) 

59 

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データが見つかりません。")) 

71 

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 

82 

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) 

94 

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 

102 

103 if not parsed_entries: 

104 raise OperationError( 

105 tr("有効なToeiDigitalTimeSheetフレームがありません。") 

106 ) 

107 

108 start_frame = min(parsed_entries) 

109 end_frame = max(parsed_entries) 

110 current_state = None 

111 states = [] 

112 blank_label_count = 0 

113 

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) 

127 

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 } 

136 

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 

148 

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 ) 

156 

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 

171 

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

183 

184 if not time_entries: 

185 raise OperationError( 

186 tr("Time RemapのFrame/secondsデータが見つかりません。") 

187 ) 

188 

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

198 

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) 

205 

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 = [] 

214 

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 ) 

233 

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

242 

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 } 

251 

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("貼り付けデータが空です。")) 

261 

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) 

274 

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) 

280 

281 raise OperationError( 

282 tr("Adobe After Effects、ToeiDigitalTimeSheet、XDTS形式を" 

283 "判別できませんでした。") 

284 ) 

285 

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 ) 

306 

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 ) 

324 

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] 

333 

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 ] 

367 

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 

378 

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 ] 

388 

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 

415 

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

430 

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 

440 

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 

451 

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 

479 

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("解析したフレーム範囲が不正です。")) 

491 

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("対象レイヤーを選択してください。")) 

507 

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 ) 

514 

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 ) 

530 

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 ) 

550 

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 

562 

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) 

574 

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

586 

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 

592 

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 

633 

634 def paste_on_timeline(self): 

635 """タイムラインで貼り付けたとき、クリップボードのタイムシートをそのまま反映する。 

636 

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 

693 

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

699 

700 

701 # ------------------------------------------------------------------ 

702 # タイムラインからのコピー(Issue #23) 

703 # ------------------------------------------------------------------ 

704 def timeline_states(self, layer_index=None): 

705 """シートの並びを ``[絵番号 または None, ...]`` として読み出す。 

706 

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 

731 

732 def after_effects_text(self, states=None, fps=None): 

733 """After Effects の Time Remap キーフレームデータを組み立てる。 

734 

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 

743 

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

759 

760 if not time_lines: 

761 raise OperationError( 

762 tr("書き出せるセルがありません。絵番号の付いたセルが要ります。") 

763 ) 

764 

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

785 

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