""" 视频裁剪节点 上传本地视频(或接入上游 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": "视频裁剪", }