130 lines
4.9 KiB
Python
130 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
from typing import Iterable, List, Tuple
|
|
|
|
from PIL import Image
|
|
|
|
|
|
def _find_frame_files(dir_path: Path) -> List[Path]:
|
|
if not dir_path.is_dir():
|
|
return []
|
|
return sorted(p for p in dir_path.iterdir() if p.is_file() and p.name.startswith("frame-") and p.suffix.lower() == ".png")
|
|
|
|
|
|
def _pair_frames(left_dir: Path, right_dir: Path) -> List[Tuple[Path, Path]]:
|
|
left_frames = {p.name: p for p in _find_frame_files(left_dir)}
|
|
right_frames = {p.name: p for p in _find_frame_files(right_dir)}
|
|
pairs = []
|
|
for name in sorted(set(left_frames) | set(right_frames)):
|
|
if name in left_frames and name in right_frames:
|
|
pairs.append((left_frames[name], right_frames[name]))
|
|
else:
|
|
missing = "left" if name not in left_frames else "right"
|
|
print(f"[WARN] missing {missing} frame: {name} in {left_dir.parent.name}")
|
|
return pairs
|
|
|
|
|
|
def _merge_pair(left_path: Path, right_path: Path, out_path: Path, distance: int, offset: int) -> None:
|
|
with Image.open(left_path) as left_img, Image.open(right_path) as right_img:
|
|
# Swap left/right placement as requested.
|
|
left = right_img.convert("RGBA")
|
|
right = left_img.convert("RGBA")
|
|
|
|
extra_top = max(offset, 0)
|
|
extra_bottom = max(-offset, 0)
|
|
y_offset = extra_top
|
|
|
|
half = distance / 2.0
|
|
left_x = int(round(-half))
|
|
right_x = int(round(left.width + half))
|
|
|
|
if distance > 0:
|
|
min_x = min(left_x, right_x)
|
|
max_x = max(left_x + left.width, right_x + right.width)
|
|
out_width = max_x - min_x
|
|
out_height = max(left.height, right.height) + extra_top + extra_bottom
|
|
merged = Image.new("RGBA", (out_width, out_height), (0, 0, 0, 0))
|
|
offset = -min_x
|
|
merged.paste(left, (left_x + offset, y_offset))
|
|
merged.paste(right, (right_x + offset, y_offset))
|
|
else:
|
|
out_width = left.width + right.width
|
|
out_height = max(left.height, right.height) + extra_top + extra_bottom
|
|
merged = Image.new("RGBA", (out_width, out_height), (0, 0, 0, 0))
|
|
|
|
if distance < 0:
|
|
midline = left.width
|
|
left_max_x = midline - left_x
|
|
right_min_x = midline - right_x
|
|
|
|
if left_max_x > 0:
|
|
left_crop = left.crop((0, 0, min(left.width, left_max_x), left.height))
|
|
merged.paste(left_crop, (left_x, y_offset))
|
|
|
|
if right_min_x < right.width:
|
|
right_crop = right.crop((max(0, right_min_x), 0, right.width, right.height))
|
|
merged.paste(right_crop, (right_x + max(0, right_min_x), y_offset))
|
|
else:
|
|
merged.paste(left, (left_x, y_offset))
|
|
merged.paste(right, (right_x, y_offset))
|
|
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
merged.save(out_path, format="PNG")
|
|
|
|
|
|
def merge_all(input_root: Path, output_root: Path, distance: int, offset: int) -> None:
|
|
if not input_root.is_dir():
|
|
raise FileNotFoundError(f"input root not found: {input_root}")
|
|
|
|
emoji_ids = [p for p in input_root.iterdir() if p.is_dir()]
|
|
for emoji_dir in sorted(emoji_ids, key=lambda p: p.name):
|
|
left_dir = emoji_dir / "left"
|
|
right_dir = emoji_dir / "right"
|
|
if not left_dir.is_dir() or not right_dir.is_dir():
|
|
print(f"[WARN] skip {emoji_dir.name}: missing left/right dirs")
|
|
continue
|
|
|
|
for left_path, right_path in _pair_frames(left_dir, right_dir):
|
|
out_path = output_root / emoji_dir.name / left_path.name
|
|
_merge_pair(left_path, right_path, out_path, distance, offset)
|
|
print(f"[OK] {emoji_dir.name}/{left_path.name}")
|
|
|
|
|
|
def _parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Merge left/right emoji frames into single frames.")
|
|
parser.add_argument(
|
|
"--input",
|
|
default="res/emoji_src",
|
|
help="Input root directory, default: res/emoji_src",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
default="res/emoji",
|
|
help="Output root directory, default: res/emoji",
|
|
)
|
|
parser.add_argument(
|
|
"--distance",
|
|
type=int,
|
|
default=0,
|
|
help="Horizontal distance between left/right. Positive moves outward (can overflow); negative moves inward and clips at midline.",
|
|
)
|
|
parser.add_argument(
|
|
"--offset",
|
|
type=int,
|
|
default=0,
|
|
help="Vertical offset padding. Positive adds blank above; negative adds blank below.",
|
|
)
|
|
return parser.parse_args(list(argv) if argv is not None else None)
|
|
|
|
|
|
def main() -> None:
|
|
args = _parse_args()
|
|
merge_all(Path(args.input), Path(args.output), int(args.distance), int(args.offset))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|