Files
workbuddy_skills/oil-motion/scripts/motion_pipeline.py
T
admin 237a09a5b0 修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。

二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。

三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
2026-10-08 22:29:52 +08:00

1089 lines
36 KiB
Python

#!/usr/bin/env python3
"""Deterministic media pipeline for interactive motion assets.
Commands:
probe Inspect video metadata with ffprobe.
extract Extract ordered frames and optionally remove a uniform key color.
normalize Correct small scale/anchor drift in fixed-subject transparent frames.
analyze Detect blank, duplicate, scale, center, and brightness anomalies.
contact Build a numbered checkerboard contact sheet.
atlas Pack ordered frames into a PNG/WebP atlas and JSON manifest.
build Run extract, optional normalize, analyze, contact, and atlas.
"""
from __future__ import annotations
import argparse
import json
import math
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from statistics import median
from typing import Any, Iterable
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageStat
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
ALPHA_NOISE_FLOOR = 8
KEY_DOMINANCE_THRESHOLD = 16
def natural_key(path: Path) -> list[Any]:
return [
int(part) if part.isdigit() else part.lower()
for part in re.split(r"(\d+)", path.name)
]
def frame_files(path: Path) -> list[Path]:
return sorted(
(
item
for item in path.iterdir()
if item.is_file() and item.suffix.lower() in IMAGE_SUFFIXES
),
key=natural_key,
)
def require_tool(name: str) -> str:
resolved = shutil.which(name)
if not resolved:
raise SystemExit(f"Required tool not found: {name}")
return resolved
def run(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def prepare_output_directory(path: Path, force: bool) -> None:
if path.exists() and any(path.iterdir()):
if not force:
raise SystemExit(
f"Output directory is not empty: {path}. Use --force only for a disposable build target."
)
shutil.rmtree(path)
path.mkdir(parents=True, exist_ok=True)
def parse_hex_color(value: str) -> tuple[int, int, int]:
normalized = value.strip().lower().replace("0x", "").lstrip("#")
if len(normalized) == 3:
normalized = "".join(character * 2 for character in normalized)
if len(normalized) != 6 or any(
character not in "0123456789abcdef" for character in normalized
):
raise SystemExit(f"Invalid key color: {value}")
return tuple(
int(normalized[index : index + 2], 16) for index in (0, 2, 4)
)
def clamp_byte(value: float) -> int:
return max(0, min(255, int(round(value))))
def color_distance(
color: tuple[int, int, int],
key: tuple[int, int, int],
) -> int:
return max(abs(color[index] - key[index]) for index in range(3))
def smoothstep(value: float) -> float:
normalized = max(0.0, min(1.0, value))
return normalized * normalized * (3.0 - 2.0 * normalized)
def distance_alpha(
distance: int,
transparent_threshold: float,
opaque_threshold: float,
) -> int:
if distance <= transparent_threshold:
return 0
if distance >= opaque_threshold:
return 255
ratio = (distance - transparent_threshold) / (
opaque_threshold - transparent_threshold
)
return clamp_byte(255 * smoothstep(ratio))
def key_channels(key: tuple[int, int, int]) -> list[int]:
strongest = max(key)
if strongest < 128:
return []
return [
index
for index, value in enumerate(key)
if value >= strongest - 16 and value >= 128
]
def key_dominance(
color: tuple[int, int, int],
key: tuple[int, int, int],
) -> int:
selected = key_channels(key)
if not selected:
return 0
other = [index for index in range(3) if index not in selected]
key_strength = min(color[index] for index in selected)
other_strength = max((color[index] for index in other), default=0)
return key_strength - other_strength
def dominance_alpha(
color: tuple[int, int, int],
key: tuple[int, int, int],
) -> int:
selected = key_channels(key)
if not selected:
return 255
other = [index for index in range(3) if index not in selected]
key_strength = min(color[index] for index in selected)
other_strength = max((color[index] for index in other), default=0)
dominance = key_strength - other_strength
if dominance <= 0:
return 255
denominator = max(1, max(key) - other_strength)
return clamp_byte(255 * (1 - min(1.0, dominance / denominator)))
def looks_key_colored(
color: tuple[int, int, int],
key: tuple[int, int, int],
distance: int,
) -> bool:
if distance <= 32:
return True
if not key_channels(key):
return True
return key_dominance(color, key) >= KEY_DOMINANCE_THRESHOLD
def despill(
color: tuple[int, int, int],
key: tuple[int, int, int],
alpha: int,
) -> tuple[int, int, int]:
if alpha >= 252:
return color
selected = key_channels(key)
other = [index for index in range(3) if index not in selected]
if not selected or not other:
return color
channels = list(color)
neutral_edge = max(channels[index] for index in other)
for index in selected:
channels[index] = min(channels[index], neutral_edge)
return channels[0], channels[1], channels[2]
def sample_border_key(image: Image.Image) -> tuple[int, int, int]:
rgba = image.convert("RGBA")
pixels = rgba.load()
width, height = rgba.size
band = max(1, min(width, height, 6))
step = max(1, min(width, height) // 256)
samples: list[tuple[int, int, int]] = []
for x in range(0, width, step):
for offset in range(band):
samples.append(pixels[x, offset][:3])
samples.append(pixels[x, height - 1 - offset][:3])
for y in range(0, height, step):
for offset in range(band):
samples.append(pixels[offset, y][:3])
samples.append(pixels[width - 1 - offset, y][:3])
return tuple(
int(round(median(sample[channel] for sample in samples)))
for channel in range(3)
)
def remove_key(
source: Path,
output: Path,
key: tuple[int, int, int],
transparent_threshold: float,
opaque_threshold: float,
) -> dict[str, int]:
image = Image.open(source).convert("RGBA")
pixels = image.load()
transparent = 0
partial = 0
for y in range(image.height):
for x in range(image.width):
red, green, blue, source_alpha = pixels[x, y]
color = (red, green, blue)
distance = color_distance(color, key)
key_like = looks_key_colored(color, key, distance)
alpha = distance_alpha(
distance,
transparent_threshold,
opaque_threshold,
)
if key_like:
alpha = min(alpha, dominance_alpha(color, key))
alpha = clamp_byte(alpha * (source_alpha / 255))
if alpha <= ALPHA_NOISE_FLOOR:
pixels[x, y] = (0, 0, 0, 0)
transparent += 1
continue
if key_like:
red, green, blue = despill(color, key, alpha)
pixels[x, y] = (red, green, blue, alpha)
if alpha < 255:
partial += 1
output.parent.mkdir(parents=True, exist_ok=True)
image.save(output)
return {"transparentPixels": transparent, "partialPixels": partial}
def probe_video(path: Path) -> dict[str, Any]:
ffprobe = require_tool("ffprobe")
result = run(
[
ffprobe,
"-v",
"error",
"-show_entries",
"format=duration,size,bit_rate:stream=index,codec_type,codec_name,width,height,pix_fmt,r_frame_rate,avg_frame_rate,nb_frames",
"-of",
"json",
str(path),
]
)
return json.loads(result.stdout)
def command_probe(args: argparse.Namespace) -> None:
source = args.input.expanduser().resolve()
if not source.is_file():
raise SystemExit(f"Input video not found: {source}")
data = probe_video(source)
if args.output:
write_json(args.output.expanduser().resolve(), data)
print(json.dumps(data, ensure_ascii=False, indent=2))
def build_video_filter(args: argparse.Namespace) -> str:
filters: list[str] = []
if args.interpolate:
filters.append(
"minterpolate="
f"fps={args.fps}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1"
)
else:
filters.append(f"fps={args.fps}")
if args.width and args.height:
filters.append(
f"scale={args.width}:{args.height}:force_original_aspect_ratio=decrease"
)
filters.append(
f"pad={args.width}:{args.height}:(ow-iw)/2:(oh-ih)/2:color=0x00FF00"
)
elif args.width:
filters.append(f"scale={args.width}:-2")
elif args.height:
filters.append(f"scale=-2:{args.height}")
return ",".join(filters)
def extract_frames(args: argparse.Namespace) -> dict[str, Any]:
source = args.input.expanduser().resolve()
output = args.output.expanduser().resolve()
if not source.is_file():
raise SystemExit(f"Input video not found: {source}")
if not 0 <= args.transparent_threshold < args.opaque_threshold <= 255:
raise SystemExit(
"Thresholds must satisfy 0 <= transparent < opaque <= 255"
)
prepare_output_directory(output, args.force)
ffmpeg = require_tool("ffmpeg")
key_enabled = args.key.lower() != "none"
with tempfile.TemporaryDirectory(prefix="oil-motion-raw-") as temp:
raw = Path(temp) if key_enabled else output
command = [ffmpeg, "-hide_banner", "-loglevel", "error"]
if args.start is not None:
command.extend(["-ss", str(args.start)])
command.extend(["-i", str(source)])
if args.duration is not None:
command.extend(["-t", str(args.duration)])
command.extend(
[
"-an",
"-vf",
build_video_filter(args),
"-vsync",
"0",
str(raw / "frame_%05d.png"),
]
)
run(command)
raw_frames = frame_files(raw)
if not raw_frames:
raise SystemExit("ffmpeg produced no frames")
key: tuple[int, int, int] | None = None
cutout_stats: dict[str, int] = {
"transparentPixels": 0,
"partialPixels": 0,
}
if key_enabled:
with Image.open(raw_frames[0]) as first:
key = (
sample_border_key(first)
if args.key.lower() == "auto"
else parse_hex_color(args.key)
)
for index, frame in enumerate(raw_frames, start=1):
stats = remove_key(
frame,
output / f"frame_{index:05d}.png",
key,
args.transparent_threshold,
args.opaque_threshold,
)
cutout_stats["transparentPixels"] += stats[
"transparentPixels"
]
cutout_stats["partialPixels"] += stats["partialPixels"]
frames = frame_files(output)
first_image = Image.open(frames[0])
width, height = first_image.size
first_image.close()
manifest = {
"source": str(source),
"frameCount": len(frames),
"fps": args.fps,
"width": width,
"height": height,
"interpolated": bool(args.interpolate),
"key": (
None
if key is None
else f"#{key[0]:02X}{key[1]:02X}{key[2]:02X}"
),
"transparentThreshold": (
args.transparent_threshold if key is not None else None
),
"opaqueThreshold": args.opaque_threshold if key is not None else None,
"cutout": cutout_stats if key is not None else None,
}
write_json(output / "extract.json", manifest)
return manifest
def command_extract(args: argparse.Namespace) -> None:
manifest = extract_frames(args)
print(json.dumps(manifest, ensure_ascii=False, indent=2))
def alpha_bbox(image: Image.Image, threshold: int = 16) -> tuple[int, int, int, int] | None:
alpha = image.convert("RGBA").getchannel("A")
mask = alpha.point(lambda value: 255 if value >= threshold else 0)
return mask.getbbox()
def normalize_frames(args: argparse.Namespace) -> dict[str, Any]:
source = args.input.expanduser().resolve()
output = args.output.expanduser().resolve()
if not source.is_dir():
raise SystemExit(f"Frame directory not found: {source}")
frames = frame_files(source)
if not frames:
raise SystemExit(f"No frames found: {source}")
prepare_output_directory(output, args.force)
measurements: list[dict[str, float | tuple[int, int, int, int] | Path]] = []
canvas_size: tuple[int, int] | None = None
for path in frames:
with Image.open(path) as opened:
image = opened.convert("RGBA")
canvas_size = canvas_size or image.size
if image.size != canvas_size:
raise SystemExit("All frames must share one canvas size")
bbox = alpha_bbox(image, args.alpha_threshold)
if bbox is None:
raise SystemExit(f"Cannot normalize blank frame: {path}")
left, top, right, bottom = bbox
measurements.append(
{
"path": path,
"bbox": bbox,
"width": right - left,
"height": bottom - top,
"centerX": (left + right) / 2,
"centerY": (top + bottom) / 2,
"bottom": bottom,
}
)
target_height = median(float(item["height"]) for item in measurements)
target_center_x = median(
float(item["centerX"]) for item in measurements
)
target_center_y = median(
float(item["centerY"]) for item in measurements
)
target_bottom = median(float(item["bottom"]) for item in measurements)
assert canvas_size is not None
applied_scales: list[float] = []
for index, item in enumerate(measurements, start=1):
path = item["path"]
bbox = item["bbox"]
assert isinstance(path, Path)
assert isinstance(bbox, tuple)
with Image.open(path) as opened:
image = opened.convert("RGBA")
crop = image.crop(bbox)
raw_scale = target_height / max(1.0, float(item["height"]))
scale = max(
1 - args.max_scale_change,
min(1 + args.max_scale_change, raw_scale),
)
applied_scales.append(scale)
resized = crop.resize(
(
max(1, round(crop.width * scale)),
max(1, round(crop.height * scale)),
),
Image.Resampling.LANCZOS,
)
if args.anchor == "bottom":
left = round(target_center_x - resized.width / 2)
top = round(target_bottom - resized.height)
else:
left = round(target_center_x - resized.width / 2)
top = round(target_center_y - resized.height / 2)
canvas = Image.new("RGBA", canvas_size, (0, 0, 0, 0))
canvas.paste(resized, (left, top), resized)
canvas.save(output / f"frame_{index:05d}.png")
manifest = {
"source": str(source),
"frameCount": len(frames),
"anchor": args.anchor,
"targetHeight": target_height,
"targetCenterX": target_center_x,
"targetCenterY": target_center_y,
"targetBottom": target_bottom,
"maxScaleChange": args.max_scale_change,
"minAppliedScale": min(applied_scales),
"maxAppliedScale": max(applied_scales),
}
write_json(output / "normalize.json", manifest)
return manifest
def command_normalize(args: argparse.Namespace) -> None:
manifest = normalize_frames(args)
print(json.dumps(manifest, ensure_ascii=False, indent=2))
def flatten_for_difference(image: Image.Image, size: int = 96) -> Image.Image:
rgba = image.convert("RGBA")
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
background.alpha_composite(rgba)
return background.convert("RGB").resize(
(size, size),
Image.Resampling.BILINEAR,
)
def visible_luminance(image: Image.Image) -> float:
rgba = image.convert("RGBA")
gray = rgba.convert("L")
alpha = rgba.getchannel("A")
if not alpha.getbbox():
return 0.0
return float(ImageStat.Stat(gray, alpha).mean[0]) / 255
def mean_difference(first: Image.Image, second: Image.Image) -> float:
difference = ImageChops.difference(first, second)
means = ImageStat.Stat(difference).mean
return float(sum(means) / len(means)) / 255
def analyze_directory(
source: Path,
alpha_threshold: int,
duplicate_threshold: float,
brightness_jump: float,
scale_jump: float,
center_jump: float,
) -> dict[str, Any]:
frames = frame_files(source)
if not frames:
raise SystemExit(f"No frames found: {source}")
frame_data: list[dict[str, Any]] = []
warnings: list[dict[str, Any]] = []
previous_flat: Image.Image | None = None
previous: dict[str, Any] | None = None
canvas_size: tuple[int, int] | None = None
for index, path in enumerate(frames):
with Image.open(path) as opened:
image = opened.convert("RGBA")
canvas_size = canvas_size or image.size
if image.size != canvas_size:
warnings.append(
{
"frame": index,
"type": "canvas-size",
"message": f"{path.name} has size {image.size}, expected {canvas_size}",
}
)
bbox = alpha_bbox(image, alpha_threshold)
item: dict[str, Any] = {
"index": index,
"file": path.name,
"width": image.width,
"height": image.height,
"bbox": list(bbox) if bbox else None,
"luminance": visible_luminance(image),
}
if bbox:
left, top, right, bottom = bbox
item.update(
{
"subjectWidthRatio": (right - left) / image.width,
"subjectHeightRatio": (bottom - top) / image.height,
"centerX": ((left + right) / 2) / image.width,
"centerY": ((top + bottom) / 2) / image.height,
"occupancy": (
((right - left) * (bottom - top))
/ (image.width * image.height)
),
}
)
else:
warnings.append(
{
"frame": index,
"type": "blank",
"message": f"{path.name} has no visible alpha content",
}
)
flat = flatten_for_difference(image)
if previous_flat is not None:
difference = mean_difference(previous_flat, flat)
item["differenceFromPrevious"] = difference
if difference < duplicate_threshold:
warnings.append(
{
"frame": index,
"type": "near-duplicate",
"value": difference,
"message": f"{path.name} is very similar to its previous frame",
}
)
if previous and bbox and previous.get("bbox"):
luminance_delta = abs(item["luminance"] - previous["luminance"])
scale_delta = abs(
item["subjectHeightRatio"] - previous["subjectHeightRatio"]
)
center_delta = math.hypot(
item["centerX"] - previous["centerX"],
item["centerY"] - previous["centerY"],
)
if luminance_delta > brightness_jump:
warnings.append(
{
"frame": index,
"type": "brightness-jump",
"value": luminance_delta,
"message": f"{path.name} changes brightness abruptly",
}
)
if scale_delta > scale_jump:
warnings.append(
{
"frame": index,
"type": "scale-jump",
"value": scale_delta,
"message": f"{path.name} changes subject scale abruptly",
}
)
if center_delta > center_jump:
warnings.append(
{
"frame": index,
"type": "center-jump",
"value": center_delta,
"message": f"{path.name} moves the subject center abruptly",
}
)
frame_data.append(item)
previous_flat = flat
previous = item
subject_heights = [
item["subjectHeightRatio"]
for item in frame_data
if "subjectHeightRatio" in item
]
centers_x = [item["centerX"] for item in frame_data if "centerX" in item]
centers_y = [item["centerY"] for item in frame_data if "centerY" in item]
differences = [
item["differenceFromPrevious"]
for item in frame_data
if "differenceFromPrevious" in item
]
return {
"source": str(source),
"frameCount": len(frames),
"canvas": list(canvas_size) if canvas_size else None,
"summary": {
"medianSubjectHeightRatio": (
median(subject_heights) if subject_heights else None
),
"subjectHeightRange": (
[min(subject_heights), max(subject_heights)]
if subject_heights
else None
),
"centerXRange": (
[min(centers_x), max(centers_x)] if centers_x else None
),
"centerYRange": (
[min(centers_y), max(centers_y)] if centers_y else None
),
"medianFrameDifference": (
median(differences) if differences else None
),
"warningCount": len(warnings),
},
"warnings": warnings,
"frames": frame_data,
}
def command_analyze(args: argparse.Namespace) -> None:
source = args.input.expanduser().resolve()
if not source.is_dir():
raise SystemExit(f"Frame directory not found: {source}")
report = analyze_directory(
source,
args.alpha_threshold,
args.duplicate_threshold,
args.brightness_jump,
args.scale_jump,
args.center_jump,
)
if args.output:
write_json(args.output.expanduser().resolve(), report)
print(json.dumps(report["summary"], ensure_ascii=False, indent=2))
def checkerboard(size: tuple[int, int], square: int = 12) -> Image.Image:
image = Image.new("RGB", size, "#f7f7f7")
draw = ImageDraw.Draw(image)
for y in range(0, size[1], square):
for x in range(0, size[0], square):
if (x // square + y // square) % 2:
draw.rectangle(
(x, y, x + square - 1, y + square - 1),
fill="#dfdfdf",
)
return image
def create_contact_sheet(
source: Path,
output: Path,
columns: int,
thumb_width: int,
) -> dict[str, Any]:
frames = frame_files(source)
if not frames:
raise SystemExit(f"No frames found: {source}")
with Image.open(frames[0]) as first:
ratio = first.height / first.width
thumb_height = max(1, round(thumb_width * ratio))
label_height = 22
rows = math.ceil(len(frames) / columns)
sheet = Image.new(
"RGB",
(columns * thumb_width, rows * (thumb_height + label_height)),
"#ffffff",
)
draw = ImageDraw.Draw(sheet)
font = ImageFont.load_default()
for index, path in enumerate(frames):
with Image.open(path) as opened:
frame = opened.convert("RGBA")
frame.thumbnail((thumb_width, thumb_height), Image.Resampling.LANCZOS)
background = checkerboard((thumb_width, thumb_height))
left = (thumb_width - frame.width) // 2
top = (thumb_height - frame.height) // 2
background.paste(frame, (left, top), frame)
column = index % columns
row = index // columns
x = column * thumb_width
y = row * (thumb_height + label_height)
sheet.paste(background, (x, y))
draw.rectangle(
(x, y + thumb_height, x + thumb_width - 1, y + thumb_height + label_height - 1),
fill="#111111",
)
draw.text(
(x + 5, y + thumb_height + 5),
f"{index:04d}",
fill="#ffffff",
font=font,
)
output.parent.mkdir(parents=True, exist_ok=True)
save_kwargs: dict[str, Any] = {}
if output.suffix.lower() in {".jpg", ".jpeg"}:
save_kwargs = {"quality": 88, "optimize": True}
sheet.save(output, **save_kwargs)
return {
"output": str(output),
"frameCount": len(frames),
"columns": columns,
"rows": rows,
"thumbWidth": thumb_width,
"thumbHeight": thumb_height,
}
def command_contact(args: argparse.Namespace) -> None:
source = args.input.expanduser().resolve()
if not source.is_dir():
raise SystemExit(f"Frame directory not found: {source}")
result = create_contact_sheet(
source,
args.output.expanduser().resolve(),
args.columns,
args.thumb_width,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
def fit_frame_to_cell(
image: Image.Image,
cell_width: int,
cell_height: int,
) -> Image.Image:
frame = image.convert("RGBA")
frame.thumbnail((cell_width, cell_height), Image.Resampling.LANCZOS)
cell = Image.new("RGBA", (cell_width, cell_height), (0, 0, 0, 0))
left = (cell_width - frame.width) // 2
top = (cell_height - frame.height) // 2
cell.alpha_composite(frame, (left, top))
return cell
def create_atlas(
source: Path,
output: Path,
manifest_path: Path,
columns: int | None,
cell_width: int,
cell_height: int,
quality: int,
lossless: bool,
max_texture: int,
) -> dict[str, Any]:
frames = frame_files(source)
if not frames:
raise SystemExit(f"No frames found: {source}")
if columns is None:
columns = max(
1,
math.ceil(
math.sqrt(
len(frames) * (cell_height / max(1, cell_width))
)
),
)
rows = math.ceil(len(frames) / columns)
atlas_width = columns * cell_width
atlas_height = rows * cell_height
if atlas_width > max_texture or atlas_height > max_texture:
raise SystemExit(
f"Atlas would be {atlas_width}x{atlas_height}, exceeding --max-texture {max_texture}. "
"Reduce the cell size, split the sequence, or use video/sequence-frame playback."
)
atlas = Image.new(
"RGBA",
(atlas_width, atlas_height),
(0, 0, 0, 0),
)
for index, path in enumerate(frames):
with Image.open(path) as opened:
cell = fit_frame_to_cell(opened, cell_width, cell_height)
atlas.alpha_composite(
cell,
(
(index % columns) * cell_width,
(index // columns) * cell_height,
),
)
output.parent.mkdir(parents=True, exist_ok=True)
if output.suffix.lower() == ".webp":
atlas.save(
output,
format="WEBP",
quality=quality,
lossless=lossless,
method=6,
)
elif output.suffix.lower() == ".png":
atlas.save(output, optimize=True)
else:
raise SystemExit("Atlas output must end in .webp or .png")
manifest = {
"version": 1,
"type": "sprite-atlas",
"asset": output.name,
"frameCount": len(frames),
"columns": columns,
"rows": rows,
"cellWidth": cell_width,
"cellHeight": cell_height,
"atlasWidth": atlas_width,
"atlasHeight": atlas_height,
"quality": quality if output.suffix.lower() == ".webp" else None,
"lossless": lossless if output.suffix.lower() == ".webp" else True,
"files": [path.name for path in frames],
}
write_json(manifest_path, manifest)
return manifest
def command_atlas(args: argparse.Namespace) -> None:
source = args.input.expanduser().resolve()
if not source.is_dir():
raise SystemExit(f"Frame directory not found: {source}")
manifest = create_atlas(
source,
args.output.expanduser().resolve(),
args.manifest.expanduser().resolve(),
args.columns,
args.cell_width,
args.cell_height,
args.quality,
args.lossless,
args.max_texture,
)
print(json.dumps(manifest, ensure_ascii=False, indent=2))
def command_build(args: argparse.Namespace) -> None:
work = args.output.expanduser().resolve()
prepare_output_directory(work, args.force)
raw_frames = work / "frames" / ("raw" if args.normalize else "final")
final_frames = work / "frames" / "final"
raw_frames.parent.mkdir(parents=True, exist_ok=True)
extract_args = argparse.Namespace(
input=args.input,
output=raw_frames,
fps=args.fps,
start=args.start,
duration=args.duration,
width=args.width,
height=args.height,
interpolate=args.interpolate,
key=args.key,
transparent_threshold=args.transparent_threshold,
opaque_threshold=args.opaque_threshold,
force=False,
)
extraction = extract_frames(extract_args)
normalization = None
if args.normalize:
normalization = normalize_frames(
argparse.Namespace(
input=raw_frames,
output=final_frames,
anchor=args.anchor,
max_scale_change=args.max_scale_change,
alpha_threshold=args.alpha_threshold,
force=False,
)
)
qa = work / "qa"
final = work / "final"
qa.mkdir(parents=True, exist_ok=True)
final.mkdir(parents=True, exist_ok=True)
analysis = analyze_directory(
final_frames,
args.alpha_threshold,
args.duplicate_threshold,
args.brightness_jump,
args.scale_jump,
args.center_jump,
)
write_json(qa / "analysis.json", analysis)
contact = create_contact_sheet(
final_frames,
qa / "contact-sheet.jpg",
args.contact_columns,
args.thumb_width,
)
atlas = create_atlas(
final_frames,
final / "motion.webp",
final / "motion.json",
args.columns,
args.cell_width,
args.cell_height,
args.quality,
args.lossless,
args.max_texture,
)
build_manifest = {
"source": str(args.input.expanduser().resolve()),
"extraction": extraction,
"normalization": normalization,
"analysisSummary": analysis["summary"],
"contactSheet": contact,
"atlas": atlas,
}
write_json(work / "build.json", build_manifest)
print(json.dumps(build_manifest, ensure_ascii=False, indent=2))
def add_extract_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--fps", type=float, default=24)
parser.add_argument("--start", type=float)
parser.add_argument("--duration", type=float)
parser.add_argument("--width", type=int)
parser.add_argument("--height", type=int)
parser.add_argument(
"--interpolate",
action="store_true",
help="Use ffmpeg motion interpolation instead of simple FPS sampling.",
)
parser.add_argument(
"--key",
default="auto",
help="'auto', 'none', or a hex key color such as '#00FF00'.",
)
parser.add_argument("--transparent-threshold", type=float, default=12)
parser.add_argument("--opaque-threshold", type=float, default=220)
def add_analysis_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--alpha-threshold", type=int, default=16)
parser.add_argument("--duplicate-threshold", type=float, default=0.004)
parser.add_argument("--brightness-jump", type=float, default=0.12)
parser.add_argument("--scale-jump", type=float, default=0.08)
parser.add_argument("--center-jump", type=float, default=0.08)
def add_atlas_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--columns", type=int)
parser.add_argument("--cell-width", type=int, required=True)
parser.add_argument("--cell-height", type=int, required=True)
parser.add_argument("--quality", type=int, default=88)
parser.add_argument("--lossless", action="store_true")
parser.add_argument("--max-texture", type=int, default=4096)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
probe = subparsers.add_parser("probe")
probe.add_argument("input", type=Path)
probe.add_argument("--output", type=Path)
probe.set_defaults(function=command_probe)
extract = subparsers.add_parser("extract")
extract.add_argument("input", type=Path)
extract.add_argument("output", type=Path)
add_extract_options(extract)
extract.add_argument("--force", action="store_true")
extract.set_defaults(function=command_extract)
normalize = subparsers.add_parser("normalize")
normalize.add_argument("input", type=Path)
normalize.add_argument("output", type=Path)
normalize.add_argument("--anchor", choices=["center", "bottom"], default="bottom")
normalize.add_argument("--max-scale-change", type=float, default=0.08)
normalize.add_argument("--alpha-threshold", type=int, default=16)
normalize.add_argument("--force", action="store_true")
normalize.set_defaults(function=command_normalize)
analyze = subparsers.add_parser("analyze")
analyze.add_argument("input", type=Path)
analyze.add_argument("--output", type=Path)
add_analysis_options(analyze)
analyze.set_defaults(function=command_analyze)
contact = subparsers.add_parser("contact")
contact.add_argument("input", type=Path)
contact.add_argument("--output", type=Path, required=True)
contact.add_argument("--columns", type=int, default=8)
contact.add_argument("--thumb-width", type=int, default=160)
contact.set_defaults(function=command_contact)
atlas = subparsers.add_parser("atlas")
atlas.add_argument("input", type=Path)
atlas.add_argument("--output", type=Path, required=True)
atlas.add_argument("--manifest", type=Path, required=True)
add_atlas_options(atlas)
atlas.set_defaults(function=command_atlas)
build = subparsers.add_parser("build")
build.add_argument("input", type=Path)
build.add_argument("output", type=Path)
add_extract_options(build)
add_analysis_options(build)
add_atlas_options(build)
build.add_argument("--normalize", action="store_true")
build.add_argument("--anchor", choices=["center", "bottom"], default="bottom")
build.add_argument("--max-scale-change", type=float, default=0.08)
build.add_argument("--contact-columns", type=int, default=8)
build.add_argument("--thumb-width", type=int, default=160)
build.add_argument("--force", action="store_true")
build.set_defaults(function=command_build)
return parser.parse_args()
def main() -> None:
args = parse_args()
if hasattr(args, "fps") and args.fps <= 0:
raise SystemExit("--fps must be positive")
if hasattr(args, "quality") and not 0 <= args.quality <= 100:
raise SystemExit("--quality must be between 0 and 100")
if hasattr(args, "max_scale_change") and not 0 <= args.max_scale_change <= 0.5:
raise SystemExit("--max-scale-change must be between 0 and 0.5")
args.function(args)
if __name__ == "__main__":
main()