修: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` 挪回原处即可。
This commit is contained in:
admin committed 2026-10-08 22:29:52 +08:00
1 parent e03465c398
commit 237a09a5b0
161 files changed
+19429 -2

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""清理 AI 闭环视频切帧中的重复帧和尾部停帧。"""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
from PIL import Image, ImageChops, ImageStat
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
def difference(left: Path, right: Path, sample_size: int = 128) -> float:
with Image.open(left) as first, Image.open(right) as second:
def visible_rgb(image: Image.Image) -> Image.Image:
if "A" not in image.getbands():
return image.convert("RGB")
rgba = image.convert("RGBA")
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
return Image.alpha_composite(background, rgba).convert("RGB")
first_rgb = visible_rgb(first)
second_rgb = visible_rgb(second)
first_rgb.thumbnail((sample_size, sample_size), Image.Resampling.LANCZOS)
second_rgb.thumbnail((sample_size, sample_size), Image.Resampling.LANCZOS)
if first_rgb.size != second_rgb.size:
second_rgb = second_rgb.resize(first_rgb.size, Image.Resampling.LANCZOS)
means = ImageStat.Stat(ImageChops.difference(first_rgb, second_rgb)).mean
return sum(means) / (3 * 255)
def frames_in(directory: Path) -> list[Path]:
return sorted(
path
for path in directory.iterdir()
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
)
def clean(args: argparse.Namespace) -> int:
source = Path(args.source).expanduser().resolve()
output = Path(args.output).expanduser().resolve()
report_path = (
Path(args.report).expanduser().resolve()
if args.report
else output.parent / f"{output.name}-cleanup.json"
)
frames = frames_in(source)
if len(frames) < 3:
raise ValueError("至少需要 3 帧")
if output.exists() and any(output.iterdir()) and not args.force:
raise FileExistsError(f"输出目录非空:{output};确认后使用 --force")
output.mkdir(parents=True, exist_ok=True)
if args.force:
for item in output.iterdir():
if item.is_file() or item.is_symlink():
item.unlink()
elif item.is_dir():
shutil.rmtree(item)
seam_window_start = max(1, len(frames) - args.seam_window)
end_reference = (
Path(args.end_reference).expanduser().resolve()
if args.end_reference
else frames[0]
)
seam_candidates = [
(index, difference(end_reference, frames[index]))
for index in range(seam_window_start, len(frames))
]
seam_index, seam_difference = min(seam_candidates, key=lambda item: item[1])
kept = [0]
dropped: list[dict[str, float | int | str]] = []
for index in range(1, seam_index):
delta = difference(frames[kept[-1]], frames[index])
if delta < args.duplicate_threshold:
dropped.append(
{
"index": index,
"file": frames[index].name,
"difference": round(delta, 8),
}
)
continue
kept.append(index)
if kept[-1] != seam_index:
kept.append(seam_index)
digits = max(5, len(str(len(kept))))
for output_index, source_index in enumerate(kept, start=1):
source_path = frames[source_index]
target = output / f"frame_{output_index:0{digits}d}{source_path.suffix.lower()}"
shutil.copy2(source_path, target)
report = {
"source": str(source),
"output": str(output),
"inputFrameCount": len(frames),
"outputFrameCount": len(kept),
"seamWindow": args.seam_window,
"endReference": str(end_reference),
"seamSourceIndex": seam_index,
"seamDifference": round(seam_difference, 8),
"duplicateThreshold": args.duplicate_threshold,
"keptSourceIndices": kept,
"droppedNearDuplicates": dropped,
"trimmedTailSourceIndices": list(range(seam_index + 1, len(frames))),
}
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(
description="选择最接近首帧的尾部接缝,并删除视觉近重复帧"
)
result.add_argument("source")
result.add_argument("output")
result.add_argument(
"--seam-window",
type=int,
default=24,
help="只在最后 N 帧中寻找最接近首帧的接缝",
)
result.add_argument(
"--end-reference",
help="单向转场的目标尾帧;省略时使用首帧,适合闭环",
)
result.add_argument(
"--duplicate-threshold",
type=float,
default=0.003,
help="与上一保留帧的归一化 RGB 差异低于此值时删除",
)
result.add_argument("--report")
result.add_argument("--force", action="store_true")
return result
if __name__ == "__main__":
try:
raise SystemExit(clean(parser().parse_args()))
except (FileNotFoundError, FileExistsError, ValueError) as error:
raise SystemExit(f"错误:{error}") from error