Coverage for paintmaskanimator/cut_folder.py: 90%

298 statements  

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

1"""Cut-folder layouts: block templates that name a cut's folders and files. 

2 

3A *template* is a tuple of :class:`Token` -- either literal text or a *field* 

4block (作品名, 話数, カット名, セル名, セル番号 ...). The same template renders 

5paths on export and, compiled to a regular expression, matches them on import, 

6so a folder written with one layout reads back with the same layout. The 

7template, not a regex, is what gets stored: a regex cannot be turned back into 

8file names, and the blocks are what the editor shows. 

9 

10Pure logic only -- no Qt -- so it is unit-testable without a display. 

11""" 

12from __future__ import annotations 

13 

14import re 

15from dataclasses import dataclass, field, replace 

16from pathlib import Path 

17from typing import Iterable, Mapping, Optional, Sequence 

18 

19#: Field ids in suggestion order. The id is protocol (saved in presets); the 

20#: label is what the editor shows. 

21FIELD_IDS = ("title", "scene", "episode", "cut", "cell", "number") 

22 

23#: Fields whose value comes from the dialog's input boxes rather than the cel. 

24VALUE_FIELDS = ("title", "scene", "episode", "cut") 

25 

26#: Fields that only make sense per cel (not in a cut-wide name). 

27CEL_FIELDS = ("cell", "number") 

28 

29#: Fields that hold a number and therefore take a digit count. 

30NUMERIC_FIELDS = ("episode", "cut", "number") 

31 

32CASES = ("keep", "upper", "lower") 

33 

34DIGIT_CHOICES = (None, 2, 3, 4, 5) 

35 

36#: Characters Windows (and therefore portable folders) refuse in a name. 

37INVALID_NAME_CHARS = '<>:"\\|?*' 

38 

39IMAGE_EXTENSIONS = ("png", "tga") 

40TIMESHEET_EXTENSIONS = ("xdts", "tdts") 

41 

42 

43@dataclass(frozen=True) 

44class Token: 

45 """One element of a template: a literal character run or a field block.""" 

46 

47 text: Optional[str] = None 

48 field: Optional[str] = None 

49 digits: Optional[int] = None 

50 case: str = "keep" 

51 prefix: str = "" 

52 suffix: str = "" 

53 

54 @property 

55 def is_field(self): 

56 return self.field is not None 

57 

58 def to_json(self): 

59 if not self.is_field: 

60 return {"text": self.text} 

61 data: dict[str, object] = {"field": self.field} 

62 if self.digits is not None: 

63 data["digits"] = int(self.digits) 

64 if self.case != "keep": 64 ↛ 65line 64 didn't jump to line 65 because the condition on line 64 was never true

65 data["case"] = self.case 

66 if self.prefix: 

67 data["prefix"] = self.prefix 

68 if self.suffix: 68 ↛ 69line 68 didn't jump to line 69 because the condition on line 68 was never true

69 data["suffix"] = self.suffix 

70 return data 

71 

72 @classmethod 

73 def from_json(cls, data): 

74 if "field" in data: 

75 name = str(data["field"]) 

76 if name not in FIELD_IDS: 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true

77 raise ValueError(f"unknown field: {name}") 

78 digits = data.get("digits") 

79 case = str(data.get("case", "keep")) 

80 return cls( 

81 field=name, 

82 digits=int(digits) if digits is not None else None, 

83 case=case if case in CASES else "keep", 

84 prefix=str(data.get("prefix", "")), 

85 suffix=str(data.get("suffix", "")), 

86 ) 

87 return cls(text=str(data.get("text", ""))) 

88 

89 

90def text(value): 

91 return Token(text=value) 

92 

93 

94def block(name, **options): 

95 return Token(field=name, **options) 

96 

97 

98def explode(template: Sequence[Token]): 

99 """Split literal runs into one token per character (the editor's unit).""" 

100 out = [] 

101 for token in template: 

102 if token.is_field: 

103 out.append(token) 

104 else: 

105 out.extend(Token(text=character) for character in token.text or "") 

106 return out 

107 

108 

109def compact(template: Iterable[Token]): 

110 """Merge adjacent literal tokens (the stored form).""" 

111 out = [] 

112 for token in template: 

113 if not token.is_field and out and not out[-1].is_field: 

114 out[-1] = Token(text=(out[-1].text or "") + (token.text or "")) 

115 elif token.is_field or token.text: 115 ↛ 112line 115 didn't jump to line 112 because the condition on line 115 was always true

116 out.append(token) 

117 return tuple(out) 

118 

119 

120def template_to_json(template): 

121 return [token.to_json() for token in compact(template)] 

122 

123 

124def template_from_json(data): 

125 return compact(Token.from_json(item) for item in data or []) 

126 

127 

128def format_field(token: Token, value) -> str: 

129 """Render one field block; an empty value drops its prefix and suffix too.""" 

130 raw = "" if value is None else str(value) 

131 if raw == "": 

132 return "" 

133 if token.field in NUMERIC_FIELDS and token.digits and raw.isdigit(): 

134 raw = raw.zfill(int(token.digits)) 

135 if token.case == "upper": 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true

136 raw = raw.upper() 

137 elif token.case == "lower": 

138 raw = raw.lower() 

139 return f"{token.prefix}{raw}{token.suffix}" 

140 

141 

142def render(template: Sequence[Token], values: Mapping[str, object]) -> str: 

143 parts = [] 

144 for token in template: 

145 if token.is_field: 

146 parts.append(format_field(token, values.get(token.field or ""))) 

147 else: 

148 parts.append(token.text or "") 

149 return "".join(parts) 

150 

151 

152def fields_in(template: Sequence[Token]): 

153 return {token.field for token in template if token.is_field} 

154 

155 

156_FIELD_PATTERNS = { 

157 "title": r".+?", 

158 "scene": r".+?", 

159 "episode": r"\d+", 

160 "cut": r"\d+", 

161 "cell": r"[^/]+?", 

162 "number": r"\d+", 

163} 

164 

165 

166def to_regex(template: Sequence[Token]) -> "re.Pattern[str]": 

167 """Compile a template to a full-match pattern with one group per field. 

168 

169 Digit counts are *not* enforced: ``A1`` and ``A00001`` both match a 4-digit 

170 セル番号, so folders written by other tools still read. A field used twice 

171 must match the same text both times (back-reference). 

172 """ 

173 parts = [] 

174 seen = set() 

175 for token in compact(template): 

176 if not token.is_field: 

177 parts.append(re.escape(token.text or "")) 

178 continue 

179 name = token.field 

180 if name in seen: 

181 body = f"(?P={name})" 

182 else: 

183 seen.add(name) 

184 body = f"(?P<{name}>{_FIELD_PATTERNS[name]})" 

185 parts.append( 

186 f"(?:{re.escape(token.prefix)}{body}{re.escape(token.suffix)})" 

187 ) 

188 return re.compile("".join(parts), re.IGNORECASE) 

189 

190 

191@dataclass 

192class CutFolderLayout: 

193 """Everything the export dialog configures, minus the per-cut values.""" 

194 

195 folder: tuple = () 

196 cell_folder: tuple = () 

197 cell_file: tuple = () 

198 timesheet: tuple = () 

199 #: Sub-folder of the cut folder for the time sheet; empty = directly inside. 

200 timesheet_folder: tuple = () 

201 extra_folders: list = field(default_factory=list) 

202 image_format: str = "png" 

203 #: ``xdts`` (exchange format) or ``tdts`` (Toei Digital Timesheet). 

204 timesheet_format: str = "xdts" 

205 

206 def to_json(self): 

207 return { 

208 "folder": template_to_json(self.folder), 

209 "cell_folder": template_to_json(self.cell_folder), 

210 "cell_file": template_to_json(self.cell_file), 

211 "timesheet": template_to_json(self.timesheet), 

212 "timesheet_folder": template_to_json(self.timesheet_folder), 

213 "extra_folders": [template_to_json(t) for t in self.extra_folders], 

214 "image_format": self.image_format, 

215 "timesheet_format": self.timesheet_format, 

216 } 

217 

218 @classmethod 

219 def from_json(cls, data): 

220 image_format = str(data.get("image_format", "png")).lower() 

221 timesheet_format = str(data.get("timesheet_format", "xdts")).lower() 

222 return cls( 

223 folder=template_from_json(data.get("folder")), 

224 cell_folder=template_from_json(data.get("cell_folder")), 

225 cell_file=template_from_json(data.get("cell_file")), 

226 timesheet=template_from_json(data.get("timesheet")), 

227 timesheet_folder=template_from_json(data.get("timesheet_folder")), 

228 extra_folders=[ 

229 template_from_json(item) 

230 for item in data.get("extra_folders") or [] 

231 ], 

232 image_format=image_format if image_format in IMAGE_EXTENSIONS else "png", 

233 timesheet_format=( 

234 timesheet_format if timesheet_format in TIMESHEET_EXTENSIONS else "xdts" 

235 ), 

236 ) 

237 

238 def copy(self): 

239 return replace(self, extra_folders=list(self.extra_folders)) 

240 

241 

242def pma_standard_layout(): 

243 """PMA標準: the per-layer folders of 連番書き出し inside a named cut folder. 

244 

245 Folder ``作品名_話数_c012``, cels ``A/A0001.png`` -- the same cel naming as 

246 the existing key-sequence export -- and the XDTS named after the folder. 

247 """ 

248 cut_name = ( 

249 block("title"), text("_"), block("episode", digits=2), text("_"), 

250 block("cut", digits=3, prefix="c"), 

251 ) 

252 return CutFolderLayout( 

253 folder=cut_name, 

254 cell_folder=(block("cell"),), 

255 cell_file=(block("cell"), block("number", digits=4)), 

256 timesheet=cut_name, 

257 timesheet_folder=(), 

258 extra_folders=[], 

259 image_format="png", 

260 ) 

261 

262 

263def ts_pool_layout(): 

264 """_ts・_pool: the layout used for compositing handoff. 

265 

266 Folder ``作品名_C002`` (no episode), cels ``A/A_0001.png``, the time sheet 

267 as ``_ts/c002.tdts`` and an empty ``_pool`` for source files such as the 

268 ``.clip``. 

269 """ 

270 return CutFolderLayout( 

271 folder=(block("title"), text("_"), block("cut", digits=3, prefix="C")), 

272 cell_folder=(block("cell"),), 

273 cell_file=(block("cell"), text("_"), block("number", digits=4)), 

274 timesheet=(block("cut", digits=3, prefix="c"),), 

275 timesheet_folder=(text("_ts"),), 

276 extra_folders=[(text("_pool"),)], 

277 image_format="png", 

278 timesheet_format="tdts", 

279 ) 

280 

281 

282BUILTIN_PRESETS = {"PMA標準": pma_standard_layout, "_ts・_pool": ts_pool_layout} 

283 

284 

285# --- export planning --------------------------------------------------------- 

286 

287@dataclass(frozen=True) 

288class CelSource: 

289 """One distinct drawing to write: a layer column and its セル番号.""" 

290 

291 layer_index: int 

292 cell: str 

293 number: int 

294 

295 

296@dataclass 

297class ExportPlan: 

298 folder_name: str 

299 #: (path relative to the cut folder, source) in write order. 

300 files: list 

301 #: Relative to the cut folder (always inside it). 

302 timesheet_path: str 

303 #: Every folder to create, relative to the cut folder (cell + extra). 

304 folders: list 

305 problems: list 

306 timesheet_format: str = "xdts" 

307 #: 作品情報 for formats whose sheet carries it (TDTS). 

308 sheet_header: dict = field(default_factory=dict) 

309 

310 @property 

311 def ok(self): 

312 return not self.problems 

313 

314 

315def invalid_name_reason(name: str) -> Optional[str]: 

316 """Why ``name`` cannot be a single file/folder name, or None if it can.""" 

317 if not name.strip(): 

318 return "empty" 

319 if name in (".", ".."): 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 return "dots" 

321 if any(character in INVALID_NAME_CHARS for character in name): 

322 return "chars" 

323 if any(ord(character) < 32 for character in name): 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true

324 return "chars" 

325 if name != name.rstrip(" ."): 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true

326 return "trailing" 

327 return None 

328 

329 

330def _split_path(value: str): 

331 return [part for part in value.split("/")] 

332 

333 

334def plan_export( 

335 layout: CutFolderLayout, 

336 values: Mapping[str, object], 

337 cels: Iterable[CelSource], 

338) -> ExportPlan: 

339 """Resolve every path the export will write and collect what is wrong. 

340 

341 Problems are returned as ``(kind, detail)`` pairs rather than raised so the 

342 dialog can show all of them next to the preview; the caller translates. 

343 """ 

344 problems = [] 

345 base = {key: values.get(key) for key in VALUE_FIELDS} 

346 

347 for key in VALUE_FIELDS: 

348 used = any( 

349 key in fields_in(template) 

350 for template in ( 

351 layout.folder, layout.cell_folder, layout.cell_file, 

352 layout.timesheet, layout.timesheet_folder, *layout.extra_folders, 

353 ) 

354 ) 

355 if used and not str(base.get(key) or "").strip(): 

356 problems.append(("missing_value", key)) 

357 

358 folder_name = render(layout.folder, base) 

359 reason = invalid_name_reason(folder_name) or ( 

360 "slash" if "/" in folder_name else None 

361 ) 

362 if reason: 

363 problems.append(("bad_name", folder_name or "", "folder")) 

364 

365 if not fields_in(layout.cell_file) & {"number"}: 

366 problems.append(("no_number", "cell_file")) 

367 

368 files = [] 

369 folders = [] 

370 seen_paths = {} 

371 for cel in cels: 

372 cel_values = dict(base, cell=cel.cell, number=cel.number) 

373 directory = render(layout.cell_folder, cel_values) 

374 name = render(layout.cell_file, cel_values) 

375 if not name: 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true

376 problems.append(("bad_name", "", "cell_file")) 

377 break 

378 relative = f"{directory}/{name}.{layout.image_format}" if directory else ( 

379 f"{name}.{layout.image_format}" 

380 ) 

381 for part in _split_path(relative): 

382 if invalid_name_reason(part): 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true

383 problems.append(("bad_name", relative, "cell_file")) 

384 break 

385 key = relative.casefold() 

386 if key in seen_paths: 

387 problems.append(("duplicate", relative)) 

388 continue 

389 seen_paths[key] = cel 

390 if directory and directory not in folders: 

391 folders.append(directory) 

392 files.append((relative, cel)) 

393 

394 for template in layout.extra_folders: 

395 extra = render(template, base).strip("/") 

396 if not extra: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true

397 continue 

398 if any(invalid_name_reason(part) for part in _split_path(extra)): 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 problems.append(("bad_name", extra, "extra_folder")) 

400 continue 

401 if extra not in folders: 401 ↛ 394line 401 didn't jump to line 394 because the condition on line 401 was always true

402 folders.append(extra) 

403 

404 timesheet_name = render(layout.timesheet, base) 

405 if invalid_name_reason(timesheet_name) or "/" in timesheet_name: 

406 problems.append(("bad_name", timesheet_name, "timesheet")) 

407 timesheet_path = f"{timesheet_name}.{layout.timesheet_format}" 

408 sheet_folder = render(layout.timesheet_folder, base).strip("/") 

409 if sheet_folder: 

410 if any(invalid_name_reason(part) for part in _split_path(sheet_folder)): 410 ↛ 411line 410 didn't jump to line 411 because the condition on line 410 was never true

411 problems.append(("bad_name", sheet_folder, "timesheet_folder")) 

412 else: 

413 if sheet_folder not in folders: 413 ↛ 415line 413 didn't jump to line 415 because the condition on line 413 was always true

414 folders.append(sheet_folder) 

415 timesheet_path = f"{sheet_folder}/{timesheet_path}" 

416 

417 # Deduplicate while keeping the first occurrence of each problem. 

418 unique = [] 

419 for problem in problems: 

420 if problem not in unique: 420 ↛ 419line 420 didn't jump to line 419 because the condition on line 420 was always true

421 unique.append(problem) 

422 return ExportPlan( 

423 folder_name=folder_name, 

424 files=files, 

425 timesheet_path=timesheet_path, 

426 folders=folders, 

427 problems=unique, 

428 timesheet_format=layout.timesheet_format, 

429 sheet_header={ 

430 name: str(values.get(name, "") or "").strip() 

431 for name in ("cut", "episode", "scene") 

432 }, 

433 ) 

434 

435 

436def guess_cut_number(name: str) -> str: 

437 """The cut number in a project name, for pre-filling カット名. 

438 

439 Prefers digits introduced by c/C/cut (``s03_c012_v2`` -> ``012``) and falls 

440 back to the last run of digits (``shot7`` -> ``7``). 

441 """ 

442 if not name: 442 ↛ 443line 442 didn't jump to line 443 because the condition on line 442 was never true

443 return "" 

444 match = re.search(r"(?:^|[^a-z])(?:cut|c)[_-]?(\d+)", name, re.IGNORECASE) 

445 if match: 

446 return match.group(1) 

447 runs = re.findall(r"\d+", name) 

448 return runs[-1] if runs else "" 

449 

450 

451# --- reading a cut folder ---------------------------------------------------- 

452 

453#: Image suffixes a cut folder's cels may use when reading one back. 

454READ_IMAGE_SUFFIXES = (".png", ".tga") 

455 

456_TRAILING_NUMBER = re.compile(r"(\d+)(?!.*\d)") 

457 

458 

459@dataclass 

460class CutFolderContents: 

461 """What :func:`scan_cut_folder` found in a cut folder. 

462 

463 ``cels`` maps a cel column (the folder name, e.g. ``A``) to its drawings by 

464 セル番号. Keys are case-folded so a time sheet column ``a`` finds folder 

465 ``A``; ``names`` keeps each column's spelling as found on disk. 

466 """ 

467 

468 root: Path 

469 timesheets: list = field(default_factory=list) 

470 cels: dict = field(default_factory=dict) 

471 names: dict = field(default_factory=dict) 

472 

473 def column(self, name): 

474 """The ``{number: path}`` drawings for a time sheet column, or ``{}``.""" 

475 return self.cels.get(str(name).strip().casefold(), {}) 

476 

477 

478def _cel_number(stem: str) -> Optional[int]: 

479 """The セル番号 of a cel file: the last run of digits in its name. 

480 

481 ``A0001`` / ``A_0001`` / ``0001`` / ``作品_c012_A_0003`` all end in the 

482 number, whichever layout (or tool) wrote them. 

483 """ 

484 match = _TRAILING_NUMBER.search(stem) 

485 if not match: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true

486 return None 

487 number = int(match.group(1)) 

488 return number if number >= 1 else None 

489 

490 

491def _column_from_stem(stem: str) -> str: 

492 """The cel column of a file lying directly in the cut folder (``A_0001``).""" 

493 match = _TRAILING_NUMBER.search(stem) 

494 head = stem[: match.start()] if match else stem 

495 return head.rstrip("_- .") 

496 

497 

498def _skipped_folder(name: str) -> bool: 

499 # ``_ts`` / ``_pool`` and hidden folders hold sheets and source files. 

500 return name.startswith(("_", ".")) 

501 

502 

503def scan_cut_folder(root) -> CutFolderContents: 

504 """List the time sheets and cel drawings in a cut folder. 

505 

506 Cels are read from the folder's direct sub-folders (one per column, the 

507 usual layout) and from images lying directly inside it. The column is the 

508 sub-folder's name, and the セル番号 the trailing digits of the file name, 

509 so folders written by other tools read as long as they follow that shape. 

510 Time sheets are looked for in the folder itself and one level down. 

511 """ 

512 root = Path(root) 

513 contents = CutFolderContents(root=root) 

514 

515 def add(column, path): 

516 number = _cel_number(path.stem) 

517 column = column.strip() 

518 if number is None or not column: 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true

519 return 

520 key = column.casefold() 

521 contents.names.setdefault(key, column) 

522 drawings = contents.cels.setdefault(key, {}) 

523 # PNG wins over TGA when both exist for the same number. 

524 if number not in drawings or path.suffix.lower() == ".png": 524 ↛ exitline 524 didn't return from function 'add' because the condition on line 524 was always true

525 drawings[number] = path 

526 

527 sheet_suffixes = (".xdts", ".xtds", ".tdts") 

528 entries = sorted(root.iterdir(), key=lambda item: item.name.casefold()) 

529 for entry in entries: 

530 if entry.name.startswith("."): 530 ↛ 531line 530 didn't jump to line 531 because the condition on line 530 was never true

531 continue 

532 if entry.is_file(): 

533 suffix = entry.suffix.lower() 

534 if suffix in sheet_suffixes: 

535 contents.timesheets.append(entry) 

536 elif suffix in READ_IMAGE_SUFFIXES: 536 ↛ 538line 536 didn't jump to line 538 because the condition on line 536 was always true

537 add(_column_from_stem(entry.stem), entry) 

538 continue 

539 if not entry.is_dir(): 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true

540 continue 

541 children = sorted(entry.iterdir(), key=lambda item: item.name.casefold()) 

542 for child in children: 

543 if not child.is_file() or child.name.startswith("."): 

544 continue 

545 suffix = child.suffix.lower() 

546 if suffix in sheet_suffixes: 

547 contents.timesheets.append(child) 

548 elif suffix in READ_IMAGE_SUFFIXES and not _skipped_folder(entry.name): 

549 add(entry.name, child) 

550 return contents 

551 

552 

553def exposure_keys(states): 

554 """Change points of one time sheet column as ``(frame, number or None)``. 

555 

556 ``states`` holds the cel number shown on each frame (``None`` = empty). 

557 """ 

558 keys = [] 

559 previous = object() 

560 for frame, state in enumerate(states): 

561 if state != previous: 

562 keys.append((frame, state)) 

563 previous = state 

564 return keys