update VideoCompress

This commit is contained in:
2026-01-11 12:40:07 +08:00
parent 6f304a634c
commit cae41d9bb0
4 changed files with 212 additions and 5928 deletions

View File

@ -1,22 +1,22 @@
{ {
"save_to": "single", "save_to": "single",
"crf": 18,
"bitrate": null, "bitrate": null,
"crf": 26, "codec": "h264",
"codec": "h264_qsv", "hwaccel": null,
"hwaccel": "qsv",
"extra": [], "extra": [],
"ffmpeg": "C:/tools/ffmpeg/bin/ffmpeg.exe", "ffmpeg": "ffmpeg",
"ffprobe": "C:/tools/ffmpeg/bin/ffprobe", "ffprobe": "ffprobe",
"manual": null, "manual": null,
"video_ext": [ "video_ext": [
".mp4", ".mp4",
".mkv" ".mkv"
], ],
"compress_dir_name": "compress_qsv", "compress_dir_name": "compress",
"resolution": null, "resolution": null,
"fps": "30", "fps": 30,
"test_video_resolution": "1920x1080", "test_video_resolution": "1920x1080",
"test_video_fps": "30", "test_video_fps": 30,
"test_video_input": "compress_video_test.mp4", "test_video_input": "compress_video_test.mp4",
"test_video_output": "compressed_video_test.mp4", "test_video_output": "compressed_video_test.mp4",
"disable_hwaccel_when_fail": true "disable_hwaccel_when_fail": true

View File

@ -122,11 +122,15 @@ def get_video_frame_count(
} }
for key in fallback_order: for key in fallback_order:
try:
try: try:
func = methods.get(key) func = methods.get(key)
if not func: if not func:
continue continue
n = func(path, stream_index) n = func(path, stream_index)
except Exception:
logging.debug(f"Errored to get frame with {key}.",exc_info=True)
continue
if isinstance(n, int) and n >= 0: if isinstance(n, int) and n >= 0:
return n return n
else: else:

View File

@ -8,23 +8,27 @@ from time import time
from rich.logging import RichHandler from rich.logging import RichHandler
from rich.progress import Progress from rich.progress import Progress
from pickle import dumps, loads from pickle import dumps, loads
from typing import Optional, Callable,Literal from typing import Optional, Callable, Literal, List, Any, TYPE_CHECKING
import atexit import atexit
import re import re
import get_frame import get_frame
import json
try:
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
HAS_PYDANTIC = True
class Config(BaseModel): class Config(BaseModel):
save_to: Literal["single", "multi"] = Field("single", description="保存到单文件夹或者每个子文件夹创建compress_dir") save_to: Literal["single", "multi"] = Field("single", description="保存到单文件夹或者每个子文件夹创建compress_dir")
crf: Optional[int] = Field(None, ge=0, le=51, description="CRF值范围0-51") crf: Optional[int] = Field(18, ge=0, le=51, description="CRF值范围0-51")
bitrate: Optional[str] = Field(None, description="比特率,格式如: 1000k, 2.5M, 1500B") bitrate: Optional[str] = Field(None, description="比特率,格式如: 1000k, 2.5M, 1500B")
codec: str = Field("h264", description="ffmpeg的codec如果使用GPU需要对应设置") codec: str = Field("h264", description="ffmpeg的codec如果使用GPU需要对应设置")
hwaccel: Optional[Literal["amf", "qsv", "cuda"]] = Field(None, description="使用GPU加速") hwaccel: Optional[Literal["amf", "qsv", "cuda"]] = Field(None, description="使用GPU加速")
extra:Optional[list[str]] = Field(None,description="插入到ffmpeg输出前的自定义参数") extra: List[str] = Field([], description="插入到ffmpeg输出前的自定义参数")
ffmpeg: str = "ffmpeg" ffmpeg: str = "ffmpeg"
ffprobe: str = "ffprobe" ffprobe: str = "ffprobe"
manual:Optional[list[str]] = Field(None,description=r"手动设置ffmpeg命令ffmpeg -i {input} {manual} {output}") manual: Optional[List[str]] = Field(None, description=r"手动设置ffmpeg命令ffmpeg -i {input} {manual} {output}")
video_ext:list[str] = Field([".mp4", ".mkv"],description="视频文件后缀,含.") video_ext: List[str] = Field([".mp4", ".mkv"], description="视频文件后缀,含.")
compress_dir_name: str = Field("compress", description="压缩文件夹名称") compress_dir_name: str = Field("compress", description="压缩文件夹名称")
resolution: Optional[str] = Field(None, description="统一到特定尺寸None为不使用缩放") resolution: Optional[str] = Field(None, description="统一到特定尺寸None为不使用缩放")
fps: int = Field(30, description="fps", ge=0) fps: int = Field(30, description="fps", ge=0)
@ -34,8 +38,6 @@ class Config(BaseModel):
test_video_output: str = "compressed_video_test.mp4" test_video_output: str = "compressed_video_test.mp4"
disable_hwaccel_when_fail: bool = Field(True, description="当运行失败时,禁用硬件加速") disable_hwaccel_when_fail: bool = Field(True, description="当运行失败时,禁用硬件加速")
@field_validator('bitrate') @field_validator('bitrate')
@classmethod @classmethod
def validate_bitrate(cls, v: Optional[str]) -> Optional[str]: def validate_bitrate(cls, v: Optional[str]) -> Optional[str]:
@ -57,15 +59,12 @@ class Config(BaseModel):
return v return v
@field_validator("compress_dir_name") @field_validator("compress_dir_name")
# @field_validator("test_video_input")
# @field_validator("test_video_output")
@classmethod @classmethod
def valid_path(cls, v: str) -> str: def valid_path(cls, v: str) -> str:
if re.search(r'[\\/:*?"<>|\x00-\x1F]', v): if re.search(r'[\\/:*?"<>|\x00-\x1F]', v):
raise ValueError("某配置不符合目录名语法") raise ValueError("某配置不符合目录名语法")
return v return v
@model_validator(mode='after') @model_validator(mode='after')
def validate_mutual_exclusive(self): def validate_mutual_exclusive(self):
crf_none = self.crf is None crf_none = self.crf is None
@ -77,31 +76,98 @@ class Config(BaseModel):
return self return self
def dump(self):
return self.model_dump()
except ImportError:
HAS_PYDANTIC = False
from dataclasses import dataclass, asdict
import copy
@dataclass
class Config:
save_to: str = "single"
crf: Optional[int] = 18
bitrate: Optional[str] = None
codec: str = "h264"
hwaccel: Optional[str] = None
extra: List[str] = []
ffmpeg: str = "ffmpeg"
ffprobe: str = "ffprobe"
manual: Optional[List[str]] = None
video_ext: List[str] = [".mp4", ".mkv"]
compress_dir_name: str = "compress"
resolution: Optional[str] = None
fps: int = 30
test_video_resolution: str = "1920x1080"
test_video_fps: int = 30
test_video_input: str = "compress_video_test.mp4"
test_video_output: str = "compressed_video_test.mp4"
disable_hwaccel_when_fail: bool = True
def update(self, other):
if isinstance(other, dict):
d = other
elif isinstance(other, Config):
d = asdict(other)
else:
return
for k, v in d.items():
if hasattr(self, k):
setattr(self, k, v)
def copy(self):
return copy.deepcopy(self)
def dump(self):
return asdict(self)
root = None root = None
if os.environ.get("INSTALL", "0") == "1": if os.environ.get("INSTALL", "0") == "1":
CFG_FILE = Path(os.getenv("APPDATA", "C:/")) / "VideoCompress" / "config.json" CFG_FILE = Path(os.getenv("APPDATA", "C:/")) / "VideoCompress" / "config.json"
else: else:
CFG_FILE = Path(sys.path[0]) / "config.json" CFG_FILE = Path(sys.path[0]) / "config.json"
CFG = {
"save_to": "single", if CFG_FILE.exists():
"crf": "18", try:
"bitrate": None, import json
"codec": "h264",
"hwaccel": None, if HAS_PYDANTIC:
"extra": [], assert BaseModel # type: ignore
"ffmpeg": "ffmpeg", assert issubclass(Config, BaseModel)
"manual": None, CFG = Config.model_validate_json(CFG_FILE.read_text())
"video_ext": [".mp4", ".mkv"], else:
"compress_dir_name": "compress", assert Config
"resolution": None, cfg:dict[str, Any] = json.loads(CFG_FILE.read_text())
"fps": "30", CFG = Config(**cfg)
"test_video_resolution": "1920x1080",
"test_video_fps": "30", get_frame.ffprobe = CFG.ffprobe
"test_video_input": "compress_video_test.mp4", logging.debug(CFG)
"test_video_output": "compressed_video_test.mp4", except KeyboardInterrupt as e:
"disable_hwaccel_when_fail": True, raise e
} except Exception as e:
logging.warning("Invalid config file, ignored.")
logging.debug(e)
else:
try:
if HAS_PYDANTIC:
if TYPE_CHECKING:
assert BaseModel # type: ignore
assert issubclass(Config, BaseModel)
CFG = Config() # type: ignore
CFG_FILE.write_text(CFG.model_dump_json(indent=4))
else:
import json
if TYPE_CHECKING:
assert Config
assert asdict # type: ignore
CFG = Config() # type: ignore
CFG_FILE.write_text(json.dumps(asdict(CFG), indent=4))
logging.info("Config file created.")
except Exception as e:
logging.warning("Failed to create config file.", exc_info=e)
def get_cmd(video_path: str | Path, output_file: str | Path) -> list[str]: def get_cmd(video_path: str | Path, output_file: str | Path) -> list[str]:
@ -110,23 +176,23 @@ def get_cmd(video_path: str | Path, output_file: str | Path) -> list[str]:
if isinstance(output_file, Path): if isinstance(output_file, Path):
output_file = str(output_file.resolve()) output_file = str(output_file.resolve())
if CFG["manual"] is not None: if CFG.manual is not None:
command = [CFG["ffmpeg"], "-hide_banner", "-i", video_path] command = [CFG.ffmpeg, "-hide_banner", "-i", video_path]
command.extend(CFG["manual"]) command.extend(CFG.manual)
command.append(output_file) command.append(output_file)
return command return command
command = [ command = [
CFG["ffmpeg"], CFG.ffmpeg,
"-hide_banner", "-hide_banner",
] ]
if CFG["hwaccel"] is not None: if CFG.hwaccel is not None:
command.extend( command.extend(
[ [
"-hwaccel", "-hwaccel",
CFG["hwaccel"], CFG.hwaccel,
"-hwaccel_output_format", "-hwaccel_output_format",
CFG["hwaccel"], CFG.hwaccel,
] ]
) )
@ -137,47 +203,47 @@ def get_cmd(video_path: str | Path, output_file: str | Path) -> list[str]:
] ]
) )
if CFG["bitrate"] is not None: if CFG.bitrate is not None:
if CFG["resolution"] is not None: if CFG.resolution is not None:
command.extend( command.extend(
[ [
"-vf", "-vf",
f"scale={CFG['resolution']}", f"scale={CFG.resolution}",
] ]
) )
command.extend( command.extend(
[ [
"-c:v", "-c:v",
CFG["codec"], CFG.codec,
"-b:v", "-b:v",
CFG["bitrate"], CFG.bitrate,
"-r", "-r",
str(CFG["fps"]), str(CFG.fps),
"-y", "-y",
] ]
) )
else: else:
if CFG["resolution"] is not None: if CFG.resolution is not None:
command.extend( command.extend(
[ [
"-vf", "-vf",
f"scale={CFG['resolution']}", f"scale={CFG.resolution}",
] ]
) )
command.extend( command.extend(
[ [
"-c:v", "-c:v",
CFG["codec"], CFG.codec,
"-global_quality", "-global_quality",
str(CFG["crf"]), str(CFG.crf),
"-r", "-r",
str(CFG["fps"]), str(CFG.fps),
"-y", "-y",
] ]
) )
command.extend(CFG["extra"]) command.extend(CFG.extra)
command.append(output_file) command.append(output_file)
logging.debug(f"Create CMD: {command}") logging.debug(f"Create CMD: {command}")
return command return command
@ -219,7 +285,7 @@ def process_video(
if compress_dir is None: if compress_dir is None:
# 在视频文件所在目录下创建 compress 子目录(如果不存在) # 在视频文件所在目录下创建 compress 子目录(如果不存在)
compress_dir = video_path.parent / CFG["compress_dir_name"] compress_dir = video_path.parent / CFG.compress_dir_name
else: else:
assert root assert root
compress_dir /= video_path.parent.relative_to(root) compress_dir /= video_path.parent.relative_to(root)
@ -277,7 +343,7 @@ def process_video(
assert result.stdout is not None assert result.stdout is not None
logging.debug(result.stdout.read()) logging.debug(result.stdout.read())
logging.debug(total) logging.debug(total)
if CFG["hwaccel"] == "mediacodec" and CFG["codec"] in [ if CFG.hwaccel == "mediacodec" and CFG.codec in [
"h264_mediacodec", "h264_mediacodec",
"hevc_mediacodec", "hevc_mediacodec",
]: ]:
@ -285,32 +351,36 @@ def process_video(
"mediacodec硬件加速器已知在较短片段上存在异常将禁用加速重试。" "mediacodec硬件加速器已知在较短片段上存在异常将禁用加速重试。"
) )
output_file.unlink(missing_ok=True) output_file.unlink(missing_ok=True)
bak = CFG.copy() bak = CFG.codec, CFG.hwaccel
CFG["hwaccel"] = None CFG.hwaccel = None
CFG["codec"] = "h264" if CFG["codec"] == "h264_mediacodec" else "hevc" CFG.codec = "h264" if CFG.codec == "h264_mediacodec" else "hevc"
assert not output_file.exists() assert not output_file.exists()
ret = process_video(video_path, compress_dir, update_func) ret = process_video(video_path, compress_dir, update_func)
CFG.update(bak) CFG.codec, CFG.hwaccel = bak
if not ret: if not ret:
logging.error("重试仍然失败。") logging.error("重试仍然失败。")
return False return False
else: else:
return True return True
elif CFG["disable_hwaccel_when_fail"] and CFG["hwaccel"] is not None: elif CFG.disable_hwaccel_when_fail and CFG.hwaccel is not None:
logging.info("正在禁用硬件加速器重试,进度条可能发生混乱。") logging.info("正在禁用硬件加速器重试,进度条可能发生混乱。")
output_file.unlink(missing_ok=True) output_file.unlink(missing_ok=True)
bak = CFG.copy() if TYPE_CHECKING:
CFG["hwaccel"] = None assert BaseModel # type: ignore
assert isinstance(CFG, BaseModel)
bak = CFG.codec, CFG.hwaccel
CFG.hwaccel = None
if ( if (
CFG["codec"].endswith("_mediacodec") CFG.codec.endswith("_mediacodec")
or CFG["codec"].endswith("_qsv") or CFG.codec.endswith("_qsv")
or CFG["codec"].endswith("_nvenc") or CFG.codec.endswith("_nvenc")
or CFG["codec"].endswith("_amf") or CFG.codec.endswith("_amf")
): ):
CFG["codec"] = CFG["codec"].split("_")[0] CFG.codec = CFG.codec.split("_")[0]
assert not output_file.exists() assert not output_file.exists()
ret = process_video(video_path, compress_dir, update_func) ret = process_video(video_path, compress_dir, update_func)
CFG.update(bak) CFG.codec, CFG.hwaccel = bak
if not ret: if not ret:
logging.error("重试仍然失败。") logging.error("重试仍然失败。")
return False return False
@ -329,7 +399,7 @@ def process_video(
def traverse_directory(root_dir: Path): def traverse_directory(root_dir: Path):
video_extensions = set(CFG["video_ext"]) video_extensions = set(CFG.video_ext)
sm = None sm = None
# 获取视频文件列表和帧数信息 # 获取视频文件列表和帧数信息
video_files:list[Path] = [] video_files:list[Path] = []
@ -338,8 +408,8 @@ def traverse_directory(root_dir: Path):
d = que.pop() d = que.pop()
for file in d.glob("*") if d.is_dir() else [d]: for file in d.glob("*") if d.is_dir() else [d]:
if ( if (
file.parent.name == CFG["compress_dir_name"] file.parent.name == CFG.compress_dir_name
or file.name == CFG["compress_dir_name"] or file.name == CFG.compress_dir_name
): ):
continue continue
if file.is_file() and file.suffix.lower() in video_extensions: if file.is_file() and file.suffix.lower() in video_extensions:
@ -426,9 +496,9 @@ def traverse_directory(root_dir: Path):
) )
prog.update(main_task, completed=completed_start + x) prog.update(main_task, completed=completed_start + x)
if CFG["save_to"] == "single": if CFG.save_to == "single":
process_video( process_video(
file, root_dir / CFG["compress_dir_name"], update_progress file, root_dir / CFG.compress_dir_name, update_progress
) )
else: else:
process_video(file, None, update_progress) process_video(file, None, update_progress)
@ -450,7 +520,7 @@ def test():
try: try:
subprocess.run( subprocess.run(
[CFG["ffmpeg"], "-version"], stdout=-3, stderr=-3 [CFG.ffmpeg, "-version"], stdout=-3, stderr=-3
).check_returncode() ).check_returncode()
except Exception as e: except Exception as e:
print(__file__) print(__file__)
@ -458,7 +528,7 @@ def test():
exit(-1) exit(-1)
try: try:
ret = subprocess.run( ret = subprocess.run(
f"{CFG['ffmpeg']} -hide_banner -f lavfi -i testsrc=duration=1:size={CFG['test_video_resolution']}:rate={CFG['test_video_fps']} -c:v libx264 -y -pix_fmt yuv420p {CFG['test_video_input']}".split(), f"{CFG.ffmpeg} -hide_banner -f lavfi -i testsrc=duration=1:size={CFG.test_video_resolution}:rate={CFG.test_video_fps} -c:v libx264 -y -pix_fmt yuv420p {CFG.test_video_input}".split(),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
@ -469,8 +539,8 @@ def test():
logging.debug(ret.stderr) logging.debug(ret.stderr)
ret.check_returncode() ret.check_returncode()
cmd = get_cmd( cmd = get_cmd(
CFG["test_video_input"], CFG.test_video_input,
CFG["test_video_output"], CFG.test_video_output,
) )
ret = subprocess.run( ret = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
@ -512,30 +582,6 @@ def main(_root=None):
logging.info("-------------------------------") logging.info("-------------------------------")
logging.info(datetime.now().strftime("Video Compress started at %Y/%m/%d %H:%M")) logging.info(datetime.now().strftime("Video Compress started at %Y/%m/%d %H:%M"))
if CFG_FILE.exists():
try:
import json
cfg: dict = json.loads(CFG_FILE.read_text())
cfg_model = Config(**cfg)
CFG.update(cfg_model.model_dump())
get_frame.ffprobe = CFG["ffprobe"]
logging.debug(cfg_model)
logging.debug(CFG)
except KeyboardInterrupt as e:
raise e
except Exception as e:
logging.warning("Invalid config file, ignored.")
logging.debug(e)
else:
try:
import json
CFG_FILE.write_text(json.dumps(CFG, indent=4))
logging.info("Config file created.")
except Exception as e:
logging.warning("Failed to create config file.", exc_info=e)
if _root is not None: if _root is not None:
root = Path(_root) root = Path(_root)
else: else:
@ -546,7 +592,7 @@ def main(_root=None):
sys.exit(1) sys.exit(1)
root = Path(sys.argv[1]) root = Path(sys.argv[1])
if root.name.lower() == CFG["compress_dir_name"].lower(): if root.name.lower() == CFG.compress_dir_name.lower():
logging.critical("请修改目标目录名为非compress。") logging.critical("请修改目标目录名为非compress。")
logging.error("Error termination via invalid input.") logging.error("Error termination via invalid input.")
sys.exit(1) sys.exit(1)
@ -576,5 +622,5 @@ def main(_root=None):
if __name__ == "__main__": if __name__ == "__main__":
sys.argv.append(r'C:\Users\flt\Documents\WeChat Files\wxid_m8h0igh8p52p22\FileStorage\Video') # sys.argv.append(r'C:\Users\flt\Documents\WeChat Files\wxid_m8h0igh8p52p22\FileStorage\Video')
main() main()

File diff suppressed because it is too large Load Diff