|
|
前情提要:OBS实现半自动【即时重放】功能 - 平台工具 - 其乐 Keylol
之前已经发过一贴了,不过之前的操作有点过于繁琐了。最近用AI更新了一下。
这次就不排版了,将就看吧。我就简单说一下了。
1、安装软件
OBS:推荐Steam版本,版本最好使用最新版本,不然会缺少【WebSocket服务器设置】
OBS Notifier:https://github.com/DmitriySalnikov/OBSNotifier
【实现各种桌面通知栏效果】
解压到任意目录即可
python 3.1.2:Download Python | Python.org
【使用py脚本需要】
个人使用的3.1.2版本,不确定其他版本是否适用
安装完成后,在OBS内设置python目录
OBS——工具——python设置——选择安装的目录位置,默认为【%USERPROFILE%\AppData\Local\Programs\Python\Python312】
2、软件设置
OBS:创建名称任意场景、场景下创意任意名称游戏源,如【自动游戏源】
OBS---设置---输出---修改以下三项
- 回放缓存---时间设置为你需要保存的即时重放时长
- 录像路径---你保存的视频位置
- 录像格式---mp4或者mkv
OBS---设置---快捷键,设置以下快捷键到你喜欢的按键上
- 启动回放缓存
- 关闭回放缓存
- 保存回放缓存
- 自动游戏源---采集当前窗口【需要和启动回放缓存一个按键,忽略冲突】
- 自动游戏源---停止采集【需要和关闭回放缓存一个按键,忽略冲突】
OBS Notifier:OBS——工具——WebSocket服务器设置——显示连接信息——获取密码后将密码填入OBS Notifier软件后点击连接
软件自动中文,功能也十分简洁,简单易懂,按照需求自行修改即可。
推荐设置【从OBS开始】会在启动OBS的同时使用脚本自动开启OBS Notifier软件
OBS——工具——脚本——点击+号——粘贴目录到%appdata%\OBSNotifier\——选择obs_notifier_autostart.lua即可
3、脚本功能介绍
脚本会识别到从游戏库目录启动的exe程序,或者是从GameWhitelist.cfg白名单启动的exe程序,然后将【自动游戏源】修改为【采集特定窗口】并采集当前启动的游戏窗口,并打开OBS的【回放缓存】功能,这期间使用快捷键保存回放缓存,OBS Notifier会提示弹窗保存成功,然后视频文件会在你设置的OBS录像路径处,脚本会将当前的视频文件转移到对应的视频文件夹内,比如你启动了求生之路2,并在游戏期间保存了回放文件,脚本会自动保存为 OBS录像路径\left 4 dead 2 文件夹内,OBS录像功能同样适应。关闭游戏后,OBS会自动修改游戏源为【用快捷键采集前台窗口】并关闭【回放缓存】功能。
个人行为:这里改为采集前台,是因为某些没设置目录的游戏或者白名单exe,可以用快捷键采集,上面设置开启回放和采集前台快捷键,按一下就可以同时开启两个功能,也可以同时关闭。你也可以直接添加目录到脚本内也行。
脚本会在脚本目录下自动创建四个文件,分别对应功能,并且以下文件都支持热加载,直接修改即可,不需要重启OBS,每个文件内都有说用说明,可以多看看。
- GameLibraries.cfg:游戏库目录位置
- GameRename.cfg:保存的视频文件夹名称,脚本会自动将保存的游戏文件夹名称自动登记到此文件内,比如求生之路2第一次保存的时候会显示为Left 4 Dead 2 - Direct3D 9,你可以修改为Left 4 Dead 2,之后保存的视频文件就会保存在Left 4 Dead 2文件夹内
- GameWhitelist.cfg:exe白名单,不需要添加游戏库目录,比如我的世界,或者别的小游戏,你就可以在此文件夹内添加javaw.exe,来直接认出我的世界
- SuffixOverride.cfg:进程后缀清除器,比如玩我的世界,进程名称会有版本,但你想将所有我的世界都存放在一个文件夹下,就可以通过添加后缀来清理。
比如把 "Minecraft 1.19.2" 变成 "Minecraft",只需输入:1.
这样脚本会自动清理Minecraft 空格 1.后面的所有后缀
4、导入脚本
在任意位置创意任意名称的txt,完整粘贴下列代码内容,并将后缀改为.py,然后用OBS调用脚本。OBS——工具——脚本——点击+号调用脚本。
PS:脚本为AI缩写,由于本人完全不懂代码编写,只给gemini提供了需求,并实现了脚本效果,目前个人使用暂未遇到什么问题,如果你遇到什么BUG或者问题,只能自行尝试AI解决了。
如果有大佬愿意改进一下脚本,那就更好了。
- <blockquote>import obspython as S
- import re, threading, time, shutil, os
- import win32gui, win32process, psutil
- import ctypes
- from ctypes import wintypes
- from pathlib import Path
- # --------------- 常量与工具打开缓存 ---------------
- GAME_CFG = 'GameWhitelist.cfg'
- LIB_CFG = 'GameLibraries.cfg'
- RENAME_CFG = 'GameRename.cfg'
- SUFFIX_CFG = 'SuffixOverride.cfg'
- MAX_TITLE_LEN = 50
- game_list_exact = []
- library_paths = []
- rename_map = {}
- suffix_regexes = []
- _cfg_mtimes = {}
- last_game = None
- tracked_game_pid = None
- tracked_game_hwnd = None
- class Data:
- OutputDir = None
- Extension = ''
- SourceName = ''
- # --------------- 脚本信息 & 属性 ---------------
- def script_description():
- return "智能游戏录制整理器"
- def script_properties():
- p = S.obs_properties_create()
- S.obs_properties_add_path(p, "output_dir", "录制输出文件夹", S.OBS_PATH_DIRECTORY, None, None)
- S.obs_properties_add_text(p, "extension", "文件扩展名(不含点)", S.OBS_TEXT_DEFAULT)
- S.obs_properties_add_text(p, "source_name", "游戏捕获源名称 (必填)", S.OBS_TEXT_DEFAULT)
- S.obs_properties_add_text(p, "libraries", "游戏库目录 (每行一个)\n例如: D:\\Steam\\steamapps\\common", S.OBS_TEXT_MULTILINE)
- return p
- def script_update(settings):
- out_str = S.obs_data_get_string(settings, "output_dir")
- Data.OutputDir = Path(out_str) if out_str else None
- Data.Extension = S.obs_data_get_string(settings, "extension").strip('.')
- Data.SourceName = S.obs_data_get_string(settings, "source_name").strip()
-
- libs_str = S.obs_data_get_string(settings, "libraries")
- if libs_str.strip() or not (_script_path() / LIB_CFG).exists():
- with open(_script_path() / LIB_CFG, 'w', encoding='utf-8') as f:
- f.write(libs_str)
-
- _load_all_configs()
- def script_load(settings):
- # 自动生成带有详细说明的本地配置文件
-
- if not (_script_path() / GAME_CFG).exists():
- with open(_script_path() / GAME_CFG, 'w', encoding='utf-8') as f:
- f.write("""# 【功能说明】
- # 强制游戏白名单(无视窗口大小和安装路径)。
- # 只要程序的 EXE 进程名在此列表中,就会被强制识别为游戏并开启录制。
- #
- # 【配置举例】
- # 直接填写 exe 名称,每行一个:
- # gta.exe
- # forzahorizon5.exe
- """)
-
- if not (_script_path() / RENAME_CFG).exists():
- with open(_script_path() / RENAME_CFG, 'w', encoding='utf-8') as f:
- f.write("""# 【功能说明】
- # 游戏重命名与自动映射表。用于将原始进程名替换为你想要的中文文件夹名。
- #
- # 【智能逻辑】
- # 1. 精确替换:gta=侠盗猎车手
- # 2. 模糊包含:如果写了 witcher=巫师3,那么 The Witcher 3 也会被识别。
- # 3. 自动收录:未记录的新游戏会自动追加到文件末尾,方便你后续直接修改。
- #
- # 【配置格式】
- # 原始名=自定义名
- """)
-
- if not (_script_path() / SUFFIX_CFG).exists():
- with open(_script_path() / SUFFIX_CFG, 'w', encoding='utf-8') as f:
- f.write("""# 【功能说明】
- # 标题尾部清理器。用于清除窗口标题中无用的后缀(如版本号)。
- # 脚本会自动删除匹配到的关键字及它后面的所有内容。
- #
- # 【配置举例】
- # 把 "Minecraft 1.19.2 - Multiplayer" 变成 "Minecraft",输入:
- # 1.
- """)
-
- lib_path = _script_path() / LIB_CFG
- if lib_path.exists():
- with open(lib_path, 'r', encoding='utf-8') as f:
- S.obs_data_set_string(settings, "libraries", f.read())
-
- _load_all_configs()
- S.obs_frontend_add_event_callback(on_event)
- start_event_hook()
- S.timer_add(check_game_process_alive, 3000)
- def script_unload():
- stop_event_hook()
- S.timer_remove(check_game_process_alive)
- # --------------- 工具 & 配置 ---------------
- def _script_path(): return Path(__file__).resolve().parent
- def _load_all_configs():
- for fname, loader in [(GAME_CFG, _load_game_list), (LIB_CFG, _load_library_paths), (RENAME_CFG, _load_rename_map), (SUFFIX_CFG, _load_suffix_regex)]:
- path = _script_path() / fname
- try: m = path.stat().st_mtime
- except FileNotFoundError: continue
- if _cfg_mtimes.get(fname) != m:
- _cfg_mtimes[fname] = m
- loader(path)
- def _load_game_list(path):
- global game_list_exact
- with open(path, encoding='utf-8') as f:
- game_list_exact = [l.strip().lower() for l in f if l.strip() and not l.startswith('#')]
- def _load_library_paths(path):
- global library_paths
- with open(path, encoding='utf-8') as f:
- library_paths = [os.path.normpath(l.strip()).lower() for l in f if l.strip() and not l.startswith('#')]
- def _load_rename_map(path):
- global rename_map
- m = {}
- with open(path, encoding='utf-8') as f:
- for line in f:
- line = line.strip()
- if line and not line.startswith('#') and '=' in line:
- k, v = line.split('=', 1)
- m[k.strip()] = v.strip()
- rename_map = m
- def _load_suffix_regex(path):
- global suffix_regexes
- r = []
- with open(path, encoding='utf-8') as f:
- for l in f:
- kw = l.strip()
- if kw and not kw.startswith('#'):
- r.append(re.compile(r'\s*' + re.escape(kw) + r'.*
- <font size="5">5、设置脚本</font>
- 说明处能修改相应信息
- <img src="https://keylol.com/forum.php?mod=image&aid=2435806&size=300x300&key=e678ffa609e7166e&nocache=yes&type=fixnone" border="0" aid="attachimg_2435806" width="300" alt="">
- 1,录制输出文件夹:此目录为你OBS视频文件输出目录,在OBS---设置---输出---录制---录像路径这里能看到,需要保证目录相同
- <ul><li>这里是脚本需要去什么目录下移动你的视频文件,并在创建好的游戏名称文件夹下放下视频文件。如果这里和OBS设置的保存目录不相同,就会提示找不到需要移动的文件。
- </li></ul>2,文件夹拓展名:OBS录制的视频格式,在OBS---设置---输出---录制---录像格式这里能看到,需要保证格式相同<ul><li>这里是脚本需要去上述目录下找到你需要移动的后缀名称为mp4的文件,如果这里和OBS设置的保存格式不相同,就会提示找不到需要移动的文件。
- </li></ul>3、游戏捕获源名称:设置你需要脚本自动更改属性的源,举例名称为【自动游戏源】
- 4,游戏库目录:就是你的游戏目录位置
- <p></p>, re.IGNORECASE))
- suffix_regexes = r
- # --------------- 核心:OBS 源注入、重置 & 内存管理 ---------------
- def inject_obs_source(hwnd, exe, raw_title):
- if not Data.SourceName: return
- source = S.obs_get_source_by_name(Data.SourceName)
- if not source: return
- try:
- cls_name = win32gui.GetClassName(hwnd)
- window_str = f"{raw_title}:{cls_name}:{exe}"
- settings = S.obs_data_create()
- S.obs_data_set_string(settings, "capture_mode", "window")
- S.obs_data_set_string(settings, "window", window_str)
- S.obs_data_set_int(settings, "window_match_priority", 2)
- S.obs_source_update(source, settings)
- S.obs_data_release(settings)
- print(f"[智能整理] 成功锁定游戏: {exe}")
- finally:
- S.obs_source_release(source)
- def reset_obs_source():
- if not Data.SourceName: return
- source = S.obs_get_source_by_name(Data.SourceName)
- if not source: return
- try:
- settings = S.obs_data_create()
- S.obs_data_set_string(settings, "capture_mode", "hotkey")
- S.obs_data_set_string(settings, "window", "")
- S.obs_source_update(source, settings)
- S.obs_data_release(settings)
- print("[智能整理] 游戏已退出,捕获源已切换至待命状态 (Hotkey)。")
- finally:
- S.obs_source_release(source)
- def check_game_process_alive():
- global tracked_game_pid, tracked_game_hwnd
- if tracked_game_pid and tracked_game_hwnd:
- is_alive = False
- try:
- if psutil.Process(tracked_game_pid).is_running() and win32gui.IsWindow(tracked_game_hwnd):
- is_alive = True
- except Exception: pass
-
- if not is_alive:
- print("[智能整理] 侦测到游戏进程关闭,正在释放资源...")
- tracked_game_pid, tracked_game_hwnd = None, None
- if S.obs_frontend_replay_buffer_active():
- S.obs_frontend_replay_buffer_stop()
- reset_obs_source()
- # --------------- 核心逻辑:双轨制判定 & 标题净化 ---------------
- def clean_title(t): return re.sub(r"[^\u4e00-\u9fffA-Za-z0-9\.\- ]+", '', t).strip()[:MAX_TITLE_LEN]
- def strip_suffix(title):
- for rx in suffix_regexes:
- title = rx.sub('', title)
- return title.strip()
- def update_game_cache_by_hwnd(hwnd):
- global last_game, tracked_game_pid, tracked_game_hwnd
- try:
- if not hwnd: return
- pid = win32process.GetWindowThreadProcessId(hwnd)[1]
- process = psutil.Process(pid)
- exe = process.name().lower()
-
- exe_path = ""
- try: exe_path = os.path.normpath(process.exe()).lower()
- except psutil.AccessDenied: pass
- raw = win32gui.GetWindowText(hwnd)
- title = strip_suffix(clean_title(raw))
- is_game = False
- if exe_path:
- for lib_dir in library_paths:
- if lib_dir and exe_path.startswith(lib_dir):
- is_game = True
- break
- if not is_game and exe in game_list_exact:
- is_game = True
- if is_game:
- if tracked_game_pid != pid:
- last_game = title or exe
- tracked_game_pid = pid
- tracked_game_hwnd = hwnd
- inject_obs_source(hwnd, exe, raw)
-
- if not S.obs_frontend_replay_buffer_active():
- print(f"[智能整理] 侦测到有效游戏 [{last_game}],已自动开启回放。")
- S.obs_frontend_replay_buffer_start()
- else:
- tracked_game_hwnd = hwnd
-
- except Exception:
- pass
- # --- Windows 底层事件钩子 ---
- EVENT_SYSTEM_FOREGROUND = 0x0003
- WINEVENT_OUTOFCONTEXT = 0x0000
- CMPFUNC = ctypes.WINFUNCTYPE(None, wintypes.HANDLE, wintypes.DWORD, wintypes.HWND, wintypes.LONG, wintypes.LONG, wintypes.DWORD, wintypes.DWORD)
- _hook_thread_obj, _windows_thread_id, _is_hooking = None, None, False
- def foreground_change_callback(hWinEventHook, event, hwnd, idObject, idChild, dwEventThread, dwmsEventTime):
- if event == EVENT_SYSTEM_FOREGROUND and hwnd: update_game_cache_by_hwnd(hwnd)
- _hook_proc = CMPFUNC(foreground_change_callback)
- def _hook_thread_worker():
- global _windows_thread_id
- _windows_thread_id = ctypes.windll.kernel32.GetCurrentThreadId()
- user32 = ctypes.windll.user32
- hook = user32.SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, 0, _hook_proc, 0, 0, WINEVENT_OUTOFCONTEXT)
- msg = wintypes.MSG()
- while user32.GetMessageW(ctypes.byref(msg), 0, 0, 0) != 0:
- user32.TranslateMessage(ctypes.byref(msg))
- user32.DispatchMessageW(ctypes.byref(msg))
- user32.UnhookWinEvent(hook)
- def start_event_hook():
- global _is_hooking, _hook_thread_obj
- if not _is_hooking:
- _is_hooking = True
- _hook_thread_obj = threading.Thread(target=_hook_thread_worker, daemon=True)
- _hook_thread_obj.start()
- threading.Timer(1.0, lambda: update_game_cache_by_hwnd(win32gui.GetForegroundWindow())).start()
- def stop_event_hook():
- global _is_hooking, _windows_thread_id
- if _is_hooking and _windows_thread_id:
- _is_hooking = False
- ctypes.windll.user32.PostThreadMessageW(_windows_thread_id, 0x0012, 0, 0)
- _windows_thread_id = None
- # --------------- 文件扫描与搬运 ---------------
- def _find_latest_file(directory: Path, ext: str) -> Path:
- latest, max_time, current_time = None, 0, time.time()
- for p in directory.glob(f"*.{ext}"):
- if p.is_file() and p.stat().st_mtime > max_time:
- max_time, latest = p.stat().st_mtime, p
- if not latest or (current_time - max_time > 60):
- raise FileNotFoundError('未找到刚刚生成的录制文件(或文件已过期)')
- return latest
- def move_file_async(src: Path, dest: Path):
- def job():
- dest.parent.mkdir(parents=True, exist_ok=True)
- for i in range(10):
- try:
- try: src.replace(dest)
- except OSError: shutil.move(str(src), str(dest))
- print(f"[智能整理] 成功移动至: {dest}")
- return
- except PermissionError: time.sleep(1)
- except Exception as e:
- print(f"[智能整理] 移动错误: {e}")
- return
- print(f"[智能整理] 移动失败,已达到最大重试次数: {src}")
- threading.Thread(target=job, daemon=True).start()
- # --------------- 事件回调 ---------------
- def on_event(event):
- _load_all_configs() # 热重载最新配置
- if event in (S.OBS_FRONTEND_EVENT_RECORDING_STOPPED, S.OBS_FRONTEND_EVENT_REPLAY_BUFFER_SAVED):
- out_dir, ext = Data.OutputDir, Data.Extension
- if not (out_dir and ext and out_dir.exists()): return
- try:
- src_file = _find_latest_file(out_dir, ext)
- except FileNotFoundError: return
- raw = last_game or 'Desktop'
- game = raw
-
- if raw and raw != 'Desktop':
- if raw in rename_map:
- game = rename_map[raw]
- else:
- match = next((v for k, v in rename_map.items() if k in raw), None)
- if match:
- game = match
- else:
- # 自动用等号格式记录新游戏
- rename_map[raw] = raw
- with open(_script_path() / RENAME_CFG, 'a', encoding='utf-8') as f:
- f.write(f"{raw}={raw}\n")
- dest_file = out_dir / game / f"{game} - {src_file.name}"
- move_file_async(src_file, dest_file)
复制代码
5、设置脚本
说明处能修改相应信息
1,录制输出文件夹:此目录为你OBS视频文件输出目录,在OBS---设置---输出---录制---录像路径这里能看到,需要保证目录相同
- 这里是脚本需要去什么目录下移动你的视频文件,并在创建好的游戏名称文件夹下放下视频文件。如果这里和OBS设置的保存目录不相同,就会提示找不到需要移动的文件。
2,文件夹拓展名:OBS录制的视频格式,在OBS---设置---输出---录制---录像格式这里能看到,需要保证格式相同- 这里是脚本需要去上述目录下找到你需要移动的后缀名称为mp4的文件,如果这里和OBS设置的保存格式不相同,就会提示找不到需要移动的文件。
3、游戏捕获源名称:设置你需要脚本自动更改属性的源,举例名称为【自动游戏源】
4,游戏库目录:就是你的游戏目录位置
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有帐号?注册
×
1、转载或引用本网站内容,必须注明本文网址:https://keylol.com/t1040304-1-1。如发文者注明禁止转载,则请勿转载
2、对于不当转载或引用本网站内容而引起的民事纷争、行政处理或其他损失,本网站不承担责任
3、对不遵守本声明或其他违法、恶意使用本网站内容者,本网站保留追究其法律责任的权利
4、所有帖子仅代表作者本人意见,不代表本社区立场
|