修: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:
1 parent
e03465c398
commit
237a09a5b0
161 files changed
+19429
-2
No files matched your search
@@ -0,0 +1,345 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT_DIR = ROOT / "scripts"
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"video_job",
|
||||
SCRIPT_DIR / "video_job.py",
|
||||
)
|
||||
assert SPEC and SPEC.loader
|
||||
VIDEO = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = VIDEO
|
||||
SPEC.loader.exec_module(VIDEO)
|
||||
|
||||
FFMPEG = shutil.which("ffmpeg")
|
||||
|
||||
# 拆开拼接,避免本测试文件自己被冲突标记扫描命中
|
||||
CONFLICT_MARKER = re.compile(
|
||||
r"^(?:{lt}|{gt}|{pipe}|{eq})$".format(
|
||||
lt="<" * 7 + r".*",
|
||||
gt=">" * 7 + r".*",
|
||||
pipe=r"\|" * 7 + r".*",
|
||||
eq="=" * 7,
|
||||
)
|
||||
)
|
||||
SKIPPED_DIRS = {".git", "__pycache__", ".venv", "node_modules"}
|
||||
|
||||
|
||||
class VideoResolutionTests(unittest.TestCase):
|
||||
def test_h3_max_accepts_supported_resolutions_and_normalizes_case(self) -> None:
|
||||
args = VIDEO.parser().parse_args(["--prompt", "测试", "--output", "out.mp4"])
|
||||
self.assertEqual(VIDEO.build_payload(args)["resolution"], "768P")
|
||||
for resolution in ("480P", "480p", "768P", "768p"):
|
||||
with self.subTest(resolution=resolution):
|
||||
args.resolution = resolution
|
||||
self.assertEqual(
|
||||
VIDEO.build_payload(args)["resolution"], resolution.upper()
|
||||
)
|
||||
|
||||
def test_h3_max_rejects_unsupported_resolutions_before_credentials_or_submit(self) -> None:
|
||||
for resolution in ("2K", "1080P", "4K", "720p"):
|
||||
with self.subTest(resolution=resolution):
|
||||
args = VIDEO.parser().parse_args(
|
||||
["--prompt", "测试", "--resolution", resolution, "--output", "out.mp4"]
|
||||
)
|
||||
with (
|
||||
mock.patch.object(VIDEO, "require_api_key") as credentials,
|
||||
mock.patch.object(VIDEO, "request_json") as request,
|
||||
self.assertRaises(ValueError) as raised,
|
||||
):
|
||||
VIDEO.generate(args)
|
||||
self.assertIn(VIDEO.DEFAULT_MODEL, str(raised.exception))
|
||||
self.assertIn(resolution, str(raised.exception))
|
||||
self.assertIn("--resolution 768P", str(raised.exception))
|
||||
self.assertIn("--resolution 480P", str(raised.exception))
|
||||
credentials.assert_not_called()
|
||||
request.assert_not_called()
|
||||
|
||||
def test_other_models_keep_their_native_resolution(self) -> None:
|
||||
args = VIDEO.parser().parse_args(
|
||||
[
|
||||
"--prompt", "测试", "--output", "out.mp4",
|
||||
"--model", "minimax/minimax-h3", "--resolution", "2K",
|
||||
]
|
||||
)
|
||||
self.assertEqual(VIDEO.build_payload(args)["resolution"], "2K")
|
||||
|
||||
|
||||
class ConflictMarkerTests(unittest.TestCase):
|
||||
def test_no_git_conflict_markers_anywhere_in_skill(self) -> None:
|
||||
offenders: list[str] = []
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if SKIPPED_DIRS & set(path.relative_to(ROOT).parts):
|
||||
continue
|
||||
data = path.read_bytes()
|
||||
if b"\x00" in data[:8192]:
|
||||
continue
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
for number, line in enumerate(text.splitlines(), start=1):
|
||||
if CONFLICT_MARKER.match(line):
|
||||
relative = path.relative_to(ROOT)
|
||||
offenders.append(f"{relative}:{number}")
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"Skill 内不得残留 Git 冲突标记:" + ", ".join(offenders),
|
||||
)
|
||||
|
||||
|
||||
def make_test_video(path: Path, frames: int = 24) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
FFMPEG or "ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"testsrc=duration={frames / 12}:size=64x64:rate=12",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def decode_rgb(path: Path) -> list[tuple[int, int, int]]:
|
||||
with Image.open(path) as opened:
|
||||
image = opened.convert("RGB")
|
||||
if hasattr(image, "get_flattened_data"):
|
||||
return list(image.get_flattened_data())
|
||||
return list(image.getdata())
|
||||
|
||||
|
||||
@unittest.skipUnless(FFMPEG, "需要 ffmpeg 才能验证尾帧提取")
|
||||
class LastFrameFallbackTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.directory.name)
|
||||
self.video = self.root / "master.mp4"
|
||||
make_test_video(self.video)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def test_extract_last_frame_returns_real_tail(self) -> None:
|
||||
output = self.root / "tail.jpg"
|
||||
|
||||
VIDEO.extract_last_frame(self.video, output)
|
||||
|
||||
self.assertTrue(output.is_file())
|
||||
self.assertGreater(output.stat().st_size, 0)
|
||||
tail = decode_rgb(output)
|
||||
reference = self.root / "reference.png"
|
||||
subprocess.run(
|
||||
[
|
||||
FFMPEG or "ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-sseof",
|
||||
"-0.2",
|
||||
"-i",
|
||||
str(self.video),
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(reference),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
first = self.root / "first.png"
|
||||
subprocess.run(
|
||||
[
|
||||
FFMPEG or "ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
str(self.video),
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(first),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def mean_delta(a: list[tuple[int, int, int]], b: list[tuple[int, int, int]]) -> float:
|
||||
return sum(
|
||||
abs(pixel_a[channel] - pixel_b[channel])
|
||||
for pixel_a, pixel_b in zip(a, b, strict=True)
|
||||
for channel in range(3)
|
||||
) / (len(a) * 3)
|
||||
|
||||
# 提取结果接近真实尾帧,而不是被静默替换成首帧
|
||||
self.assertLess(mean_delta(tail, decode_rgb(reference)), 12)
|
||||
self.assertGreater(mean_delta(tail, decode_rgb(first)), 1)
|
||||
|
||||
def run_generate(self, final_response: dict) -> tuple[Path, Path, dict]:
|
||||
output = self.root / "out.mp4"
|
||||
last_frame_output = self.root / "tail-out.jpg"
|
||||
metadata = self.root / "meta.json"
|
||||
args = Namespace(
|
||||
prompt="测试",
|
||||
prompt_file=None,
|
||||
first_frame=None,
|
||||
last_frame=None,
|
||||
loop_frame=False,
|
||||
reference_image=[],
|
||||
model="minimax/minimax-h3",
|
||||
resolution="768p",
|
||||
ratio=None,
|
||||
duration=5,
|
||||
seed=None,
|
||||
frames=None,
|
||||
stage="pilot",
|
||||
segment_index=1,
|
||||
pilot_approval=None,
|
||||
continuity_mode=None,
|
||||
previous_tail=None,
|
||||
frame_chain_manifest=None,
|
||||
output=str(output),
|
||||
last_frame_output=str(last_frame_output),
|
||||
metadata=str(metadata),
|
||||
poll_interval=0.01,
|
||||
timeout=30.0,
|
||||
force=False,
|
||||
)
|
||||
|
||||
def fake_request(method: str, url: str, api_key: str, payload=None) -> dict:
|
||||
if method == "POST":
|
||||
return {"id": "job-1"}
|
||||
return final_response
|
||||
|
||||
def fake_download(url: str, target: Path) -> None:
|
||||
if "last-frame" in url:
|
||||
target.write_bytes((self.root / "api-tail.png").read_bytes())
|
||||
else:
|
||||
shutil.copy2(self.video, target)
|
||||
|
||||
api_tail = Image.new("RGB", (64, 64), (12, 34, 56))
|
||||
api_tail.save(self.root / "api-tail.png")
|
||||
|
||||
with (
|
||||
mock.patch.object(VIDEO, "request_json", side_effect=fake_request),
|
||||
mock.patch.object(VIDEO, "download", side_effect=fake_download),
|
||||
mock.patch.object(VIDEO, "require_api_key", return_value="key"),
|
||||
):
|
||||
VIDEO.generate(args)
|
||||
return output, last_frame_output, json.loads(
|
||||
metadata.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
def test_api_last_frame_is_preferred(self) -> None:
|
||||
_, last_frame_output, metadata = self.run_generate(
|
||||
{
|
||||
"status": "succeeded",
|
||||
"video_url": "https://example.com/video.mp4",
|
||||
"last_frame_url": "https://example.com/last-frame.jpg",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(last_frame_output.is_file())
|
||||
self.assertEqual(decode_rgb(last_frame_output)[0], (12, 34, 56))
|
||||
self.assertEqual(metadata["lastFrame"]["source"], "api")
|
||||
self.assertEqual(
|
||||
metadata["lastFrame"]["path"], str(last_frame_output.resolve())
|
||||
)
|
||||
|
||||
def test_missing_api_last_frame_falls_back_to_video(self) -> None:
|
||||
_, last_frame_output, metadata = self.run_generate(
|
||||
{
|
||||
"status": "succeeded",
|
||||
"video_url": "https://example.com/video.mp4",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(last_frame_output.is_file())
|
||||
with Image.open(last_frame_output) as image:
|
||||
self.assertEqual(image.size, (64, 64))
|
||||
self.assertEqual(metadata["lastFrame"]["source"], "video-fallback")
|
||||
|
||||
def test_default_last_frame_output_is_always_produced(self) -> None:
|
||||
output = self.root / "default.mp4"
|
||||
metadata = self.root / "default.meta.json"
|
||||
args = Namespace(
|
||||
prompt="测试",
|
||||
prompt_file=None,
|
||||
first_frame=None,
|
||||
last_frame=None,
|
||||
loop_frame=False,
|
||||
reference_image=[],
|
||||
model="minimax/minimax-h3",
|
||||
resolution="768p",
|
||||
ratio=None,
|
||||
duration=5,
|
||||
seed=None,
|
||||
frames=None,
|
||||
stage="pilot",
|
||||
segment_index=1,
|
||||
pilot_approval=None,
|
||||
continuity_mode=None,
|
||||
previous_tail=None,
|
||||
frame_chain_manifest=None,
|
||||
output=str(output),
|
||||
last_frame_output=None,
|
||||
metadata=str(metadata),
|
||||
poll_interval=0.01,
|
||||
timeout=30.0,
|
||||
force=False,
|
||||
)
|
||||
with (
|
||||
mock.patch.object(
|
||||
VIDEO,
|
||||
"request_json",
|
||||
side_effect=[
|
||||
{"id": "job-1"},
|
||||
{
|
||||
"status": "succeeded",
|
||||
"video_url": "https://example.com/video.mp4",
|
||||
},
|
||||
],
|
||||
),
|
||||
mock.patch.object(
|
||||
VIDEO,
|
||||
"download",
|
||||
side_effect=lambda url, target: shutil.copy2(self.video, target),
|
||||
),
|
||||
mock.patch.object(VIDEO, "require_api_key", return_value="key"),
|
||||
):
|
||||
VIDEO.generate(args)
|
||||
|
||||
default_tail = self.root / "default-last-frame.jpg"
|
||||
self.assertTrue(default_tail.is_file())
|
||||
payload = json.loads(metadata.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["lastFrame"]["source"], "video-fallback")
|
||||
self.assertEqual(
|
||||
payload["lastFrame"]["path"], str(default_tail.resolve())
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user