Files
comfyui_o1key/nodes/video_trim.py
T
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

227 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
视频裁剪节点
上传本地视频(或接入上游 VIDEO),用前端时间轴选段,按 [开始, 结束] 物理裁剪。
为什么物理裁剪而不用 VideoFromFile 的惰性 trim:
r2_uploader.upload_video / 各生视频节点读取的是 get_stream_source()(整段原文件),
惰性 trim 窗口在上传时会被忽略。这里用 ffmpeg 真正切出一段独立 mp4,
保证预览、上传、保存三条路径都拿到裁剪后的内容。
ffmpeg 解析顺序:系统 PATH → imageio-ffmpeg 自带二进制(随整合包分发,
无需用户单独安装 ffmpeg)。
"""
import io
import os
import re
import shutil
import subprocess
import tempfile
try:
from comfy_api.latest import InputImpl
_VIDEO_OK = True
except Exception:
_VIDEO_OK = False
# ── ffmpeg / 时长解析 ───────────────────────────────────────────────────────────
def _resolve_ffmpeg() -> str:
exe = shutil.which("ffmpeg")
if exe:
return exe
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
pass
raise RuntimeError(
"未找到 ffmpeg。请在便携 Python 中执行:"
"python_embeded\\python.exe -m pip install imageio-ffmpeg"
)
_DUR_RE = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
def _probe_duration(src: str) -> float | None:
"""取视频时长(秒)。优先 ffprobe;缺失时解析 `ffmpeg -i` 的 stderr。"""
ffprobe = shutil.which("ffprobe")
if ffprobe:
try:
out = subprocess.run(
[ffprobe, "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", src],
capture_output=True, text=True, timeout=30,
)
val = (out.stdout or "").strip()
if val:
return float(val)
except Exception:
pass
# 退化:imageio-ffmpeg 只带 ffmpeg,没有 ffprobe → 解析 ffmpeg -i 输出
try:
ffmpeg = _resolve_ffmpeg()
out = subprocess.run([ffmpeg, "-i", src], capture_output=True, text=True, timeout=30)
m = _DUR_RE.search(out.stderr or "")
if m:
h, mi, s = int(m.group(1)), int(m.group(2)), float(m.group(3))
return h * 3600 + mi * 60 + s
except Exception:
pass
return None
# ── 源文件解析 ──────────────────────────────────────────────────────────────────
def _resolve_source(video_obj, video_path: str):
"""返回 (源文件路径, 是否为临时文件)。临时文件用完需删除。"""
if video_obj is not None and hasattr(video_obj, "get_stream_source"):
source = video_obj.get_stream_source()
if isinstance(source, io.BytesIO):
fd, tmp = tempfile.mkstemp(suffix=".mp4", prefix="o1key_trim_src_")
os.close(fd)
source.seek(0)
with open(tmp, "wb") as f:
f.write(source.read())
return tmp, True
return source, False
p = (video_path or "").strip().strip('"').strip("'")
if not p:
raise ValueError("请先点节点上的「上传视频」按钮,或连接一个「视频」输入。")
if not os.path.isfile(p):
raise ValueError(f"视频文件不存在:{p}")
return p, False
class O1keyVideoTrim:
"""
视频裁剪:上传本地视频 → 时间轴拖拽选段 → 输出裁剪后的 VIDEO。
- 「视频路径」由前端「上传视频」按钮自动填入(也可手动粘贴绝对路径)。
- 「开始时间」由时间轴拖拽同步,单位秒。
- 「固定时长」> 0 时:裁剪区间长度固定为该值,前端可整体拖动这个窗口(定时快剪)。
- 「固定时长」= 0 时:用「结束时间」;结束时间为 0 表示到片尾(自由两端拖拽)。
- 可选「视频」输入:连接上游 VIDEO 时优先裁剪它,忽略「视频路径」。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"视频路径": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "点节点上的「上传视频」按钮,或粘贴视频绝对路径",
}),
"开始时间": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.1,
"tooltip": "裁剪起点(秒)",
}),
"结束时间": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.1,
"tooltip": "裁剪终点(秒);0 表示到片尾。固定时长 > 0 时忽略此项",
}),
"固定时长": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 86400.0, "step": 0.5,
"tooltip": "> 0 时裁剪区间长度固定为该值(定时快剪);0 表示用结束时间",
}),
},
"optional": {
"视频": ("VIDEO",),
},
}
RETURN_TYPES = ("VIDEO", "FLOAT")
RETURN_NAMES = ("视频", "时长")
FUNCTION = "trim"
CATEGORY = "comfyui_o1key/Utils"
def trim(self, 视频路径: str = "", 开始时间: float = 0.0, 固定时长: float = 0.0,
结束时间: float = 0.0, 视频=None):
if not _VIDEO_OK:
raise RuntimeError("当前环境缺少 comfy_api,无法输出 VIDEO 类型。")
src, is_tmp = _resolve_source(视频, 视频路径)
try:
duration = _probe_duration(src)
start = max(0.0, float(开始时间))
fixed = max(0.0, float(固定时长))
# 计算结束时间
if fixed > 0.0:
# 固定时长模式:窗口整体不超过片尾
if duration and fixed >= duration:
start, end = 0.0, duration
else:
if duration:
start = min(start, max(duration - fixed, 0.0))
end = start + fixed
if duration:
end = min(end, duration)
else:
end = float(结束时间)
if end <= 0.0:
end = duration if duration else 0.0 # 0 → 到片尾
if duration:
end = min(end, duration)
start = min(start, max(duration - 0.05, 0.0))
if end > 0.0 and end <= start:
raise ValueError(
f"结束时间({end:.2f}s)必须大于开始时间({start:.2f}s)。"
)
# 整段未裁剪且源为磁盘文件:直接透传,避免无谓重编码
full_range = (
duration is not None and start <= 0.01 and end >= duration - 0.05
)
if full_range and not is_tmp:
return (InputImpl.VideoFromFile(src), float(duration))
seg_dur = (end - start) if end > 0.0 else (
(duration - start) if duration else 0.0
)
if seg_dur <= 0.0:
raise ValueError("裁剪区间长度为 0,请调整开始/结束时间或固定时长。")
fd, out_path = tempfile.mkstemp(suffix=".mp4", prefix="o1key_trim_")
os.close(fd)
ffmpeg = _resolve_ffmpeg()
cmd = [
ffmpeg, "-y",
"-ss", f"{start:.3f}",
"-i", src,
"-t", f"{seg_dur:.3f}",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-movflags", "+faststart",
out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0 or not os.path.isfile(out_path) or os.path.getsize(out_path) == 0:
tail = (proc.stderr or "").strip().splitlines()[-8:]
raise RuntimeError("ffmpeg 裁剪失败:\n" + "\n".join(tail))
return (InputImpl.VideoFromFile(out_path), float(seg_dur))
finally:
if is_tmp:
try:
os.remove(src)
except Exception:
pass
NODE_CLASS_MAPPINGS = {
"O1keyVideoTrim": O1keyVideoTrim,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"O1keyVideoTrim": "视频裁剪",
}