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
« 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.
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.
10Pure logic only -- no Qt -- so it is unit-testable without a display.
11"""
12from __future__ import annotations
14import re
15from dataclasses import dataclass, field, replace
16from pathlib import Path
17from typing import Iterable, Mapping, Optional, Sequence
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")
23#: Fields whose value comes from the dialog's input boxes rather than the cel.
24VALUE_FIELDS = ("title", "scene", "episode", "cut")
26#: Fields that only make sense per cel (not in a cut-wide name).
27CEL_FIELDS = ("cell", "number")
29#: Fields that hold a number and therefore take a digit count.
30NUMERIC_FIELDS = ("episode", "cut", "number")
32CASES = ("keep", "upper", "lower")
34DIGIT_CHOICES = (None, 2, 3, 4, 5)
36#: Characters Windows (and therefore portable folders) refuse in a name.
37INVALID_NAME_CHARS = '<>:"\\|?*'
39IMAGE_EXTENSIONS = ("png", "tga")
40TIMESHEET_EXTENSIONS = ("xdts", "tdts")
43@dataclass(frozen=True)
44class Token:
45 """One element of a template: a literal character run or a field block."""
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 = ""
54 @property
55 def is_field(self):
56 return self.field is not None
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
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", "")))
90def text(value):
91 return Token(text=value)
94def block(name, **options):
95 return Token(field=name, **options)
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
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)
120def template_to_json(template):
121 return [token.to_json() for token in compact(template)]
124def template_from_json(data):
125 return compact(Token.from_json(item) for item in data or [])
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}"
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)
152def fields_in(template: Sequence[Token]):
153 return {token.field for token in template if token.is_field}
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}
166def to_regex(template: Sequence[Token]) -> "re.Pattern[str]":
167 """Compile a template to a full-match pattern with one group per field.
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)
191@dataclass
192class CutFolderLayout:
193 """Everything the export dialog configures, minus the per-cut values."""
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"
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 }
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 )
238 def copy(self):
239 return replace(self, extra_folders=list(self.extra_folders))
242def pma_standard_layout():
243 """PMA標準: the per-layer folders of 連番書き出し inside a named cut folder.
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 )
263def ts_pool_layout():
264 """_ts・_pool: the layout used for compositing handoff.
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 )
282BUILTIN_PRESETS = {"PMA標準": pma_standard_layout, "_ts・_pool": ts_pool_layout}
285# --- export planning ---------------------------------------------------------
287@dataclass(frozen=True)
288class CelSource:
289 """One distinct drawing to write: a layer column and its セル番号."""
291 layer_index: int
292 cell: str
293 number: int
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)
310 @property
311 def ok(self):
312 return not self.problems
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
330def _split_path(value: str):
331 return [part for part in value.split("/")]
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.
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}
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))
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"))
365 if not fields_in(layout.cell_file) & {"number"}:
366 problems.append(("no_number", "cell_file"))
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))
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)
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}"
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 )
436def guess_cut_number(name: str) -> str:
437 """The cut number in a project name, for pre-filling カット名.
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 ""
451# --- reading a cut folder ----------------------------------------------------
453#: Image suffixes a cut folder's cels may use when reading one back.
454READ_IMAGE_SUFFIXES = (".png", ".tga")
456_TRAILING_NUMBER = re.compile(r"(\d+)(?!.*\d)")
459@dataclass
460class CutFolderContents:
461 """What :func:`scan_cut_folder` found in a cut folder.
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 """
468 root: Path
469 timesheets: list = field(default_factory=list)
470 cels: dict = field(default_factory=dict)
471 names: dict = field(default_factory=dict)
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(), {})
478def _cel_number(stem: str) -> Optional[int]:
479 """The セル番号 of a cel file: the last run of digits in its name.
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
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("_- .")
498def _skipped_folder(name: str) -> bool:
499 # ``_ts`` / ``_pool`` and hidden folders hold sheets and source files.
500 return name.startswith(("_", "."))
503def scan_cut_folder(root) -> CutFolderContents:
504 """List the time sheets and cel drawings in a cut folder.
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)
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
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
553def exposure_keys(states):
554 """Change points of one time sheet column as ``(frame, number or None)``.
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