回复
5
查看
197
收藏
2

0

赠楼

0%

赠楼率

972

蒸汽

246

主题

1348

帖子

2897

积分
发表于 3 小时前 · 四川 | 显示全部楼层 |阅读模式
前情提要: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解决了。
如果有大佬愿意改进一下脚本,那就更好了。
  1. <blockquote>import obspython as S
  2. import re, threading, time, shutil, os
  3. import win32gui, win32process, psutil
  4. import ctypes
  5. from ctypes import wintypes
  6. from pathlib import Path


  7. # --------------- 常量与工具打开缓存 ---------------
  8. GAME_CFG       = 'GameWhitelist.cfg'
  9. LIB_CFG        = 'GameLibraries.cfg'
  10. RENAME_CFG     = 'GameRename.cfg'  
  11. SUFFIX_CFG     = 'SuffixOverride.cfg'
  12. MAX_TITLE_LEN  = 50


  13. game_list_exact = []  
  14. library_paths = []
  15. rename_map = {}
  16. suffix_regexes = []
  17. _cfg_mtimes = {}


  18. last_game = None
  19. tracked_game_pid = None  
  20. tracked_game_hwnd = None


  21. class Data:
  22.     OutputDir = None
  23.     Extension = ''
  24.     SourceName = ''


  25. # --------------- 脚本信息 & 属性 ---------------
  26. def script_description():
  27.     return "智能游戏录制整理器"


  28. def script_properties():
  29.     p = S.obs_properties_create()
  30.     S.obs_properties_add_path(p, "output_dir", "录制输出文件夹", S.OBS_PATH_DIRECTORY, None, None)
  31.     S.obs_properties_add_text(p, "extension", "文件扩展名(不含点)", S.OBS_TEXT_DEFAULT)
  32.     S.obs_properties_add_text(p, "source_name", "游戏捕获源名称 (必填)", S.OBS_TEXT_DEFAULT)
  33.     S.obs_properties_add_text(p, "libraries", "游戏库目录 (每行一个)\n例如: D:\\Steam\\steamapps\\common", S.OBS_TEXT_MULTILINE)
  34.     return p


  35. def script_update(settings):
  36.     out_str = S.obs_data_get_string(settings, "output_dir")
  37.     Data.OutputDir = Path(out_str) if out_str else None
  38.     Data.Extension = S.obs_data_get_string(settings, "extension").strip('.')
  39.     Data.SourceName = S.obs_data_get_string(settings, "source_name").strip()
  40.    
  41.     libs_str = S.obs_data_get_string(settings, "libraries")
  42.     if libs_str.strip() or not (_script_path() / LIB_CFG).exists():
  43.         with open(_script_path() / LIB_CFG, 'w', encoding='utf-8') as f:
  44.             f.write(libs_str)
  45.    
  46.     _load_all_configs()


  47. def script_load(settings):
  48.     # 自动生成带有详细说明的本地配置文件
  49.    
  50.     if not (_script_path() / GAME_CFG).exists():
  51.         with open(_script_path() / GAME_CFG, 'w', encoding='utf-8') as f:
  52.             f.write("""# 【功能说明】
  53. # 强制游戏白名单(无视窗口大小和安装路径)。
  54. # 只要程序的 EXE 进程名在此列表中,就会被强制识别为游戏并开启录制。
  55. #
  56. # 【配置举例】
  57. # 直接填写 exe 名称,每行一个:
  58. # gta.exe
  59. # forzahorizon5.exe
  60. """)
  61.             
  62.     if not (_script_path() / RENAME_CFG).exists():
  63.         with open(_script_path() / RENAME_CFG, 'w', encoding='utf-8') as f:
  64.             f.write("""# 【功能说明】
  65. # 游戏重命名与自动映射表。用于将原始进程名替换为你想要的中文文件夹名。
  66. #
  67. # 【智能逻辑】
  68. # 1. 精确替换:gta=侠盗猎车手
  69. # 2. 模糊包含:如果写了 witcher=巫师3,那么 The Witcher 3 也会被识别。
  70. # 3. 自动收录:未记录的新游戏会自动追加到文件末尾,方便你后续直接修改。
  71. #
  72. # 【配置格式】
  73. # 原始名=自定义名
  74. """)
  75.             
  76.     if not (_script_path() / SUFFIX_CFG).exists():
  77.         with open(_script_path() / SUFFIX_CFG, 'w', encoding='utf-8') as f:
  78.             f.write("""# 【功能说明】
  79. # 标题尾部清理器。用于清除窗口标题中无用的后缀(如版本号)。
  80. # 脚本会自动删除匹配到的关键字及它后面的所有内容。
  81. #
  82. # 【配置举例】
  83. # 把 "Minecraft 1.19.2 - Multiplayer" 变成 "Minecraft",输入:
  84. # 1.
  85. """)
  86.             
  87.     lib_path = _script_path() / LIB_CFG
  88.     if lib_path.exists():
  89.         with open(lib_path, 'r', encoding='utf-8') as f:
  90.             S.obs_data_set_string(settings, "libraries", f.read())
  91.             
  92.     _load_all_configs()
  93.     S.obs_frontend_add_event_callback(on_event)
  94.     start_event_hook()
  95.     S.timer_add(check_game_process_alive, 3000)


  96. def script_unload():
  97.     stop_event_hook()
  98.     S.timer_remove(check_game_process_alive)


  99. # --------------- 工具 & 配置 ---------------
  100. def _script_path(): return Path(__file__).resolve().parent


  101. def _load_all_configs():
  102.     for fname, loader in [(GAME_CFG, _load_game_list), (LIB_CFG, _load_library_paths), (RENAME_CFG, _load_rename_map), (SUFFIX_CFG, _load_suffix_regex)]:
  103.         path = _script_path() / fname
  104.         try: m = path.stat().st_mtime
  105.         except FileNotFoundError: continue
  106.         if _cfg_mtimes.get(fname) != m:
  107.             _cfg_mtimes[fname] = m
  108.             loader(path)


  109. def _load_game_list(path):
  110.     global game_list_exact
  111.     with open(path, encoding='utf-8') as f:
  112.         game_list_exact = [l.strip().lower() for l in f if l.strip() and not l.startswith('#')]


  113. def _load_library_paths(path):
  114.     global library_paths
  115.     with open(path, encoding='utf-8') as f:
  116.         library_paths = [os.path.normpath(l.strip()).lower() for l in f if l.strip() and not l.startswith('#')]


  117. def _load_rename_map(path):
  118.     global rename_map
  119.     m = {}
  120.     with open(path, encoding='utf-8') as f:
  121.         for line in f:
  122.             line = line.strip()
  123.             if line and not line.startswith('#') and '=' in line:
  124.                 k, v = line.split('=', 1)
  125.                 m[k.strip()] = v.strip()
  126.     rename_map = m


  127. def _load_suffix_regex(path):
  128.     global suffix_regexes
  129.     r = []
  130.     with open(path, encoding='utf-8') as f:
  131.         for l in f:
  132.             kw = l.strip()
  133.             if kw and not kw.startswith('#'):
  134.                 r.append(re.compile(r'\s*' + re.escape(kw) + r'.*
  135. <font size="5">5、设置脚本</font>
  136. 说明处能修改相应信息
  137. <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="">
  138. 1,录制输出文件夹:此目录为你OBS视频文件输出目录,在OBS---设置---输出---录制---录像路径这里能看到,需要保证目录相同
  139. <ul><li>这里是脚本需要去什么目录下移动你的视频文件,并在创建好的游戏名称文件夹下放下视频文件。如果这里和OBS设置的保存目录不相同,就会提示找不到需要移动的文件。
  140. </li></ul>2,文件夹拓展名:OBS录制的视频格式,在OBS---设置---输出---录制---录像格式这里能看到,需要保证格式相同<ul><li>这里是脚本需要去上述目录下找到你需要移动的后缀名称为mp4的文件,如果这里和OBS设置的保存格式不相同,就会提示找不到需要移动的文件。
  141. </li></ul>3、游戏捕获源名称:设置你需要脚本自动更改属性的源,举例名称为【自动游戏源】
  142. 4,游戏库目录:就是你的游戏目录位置




  143. <p></p>, re.IGNORECASE))
  144.     suffix_regexes = r


  145. # --------------- 核心:OBS 源注入、重置 & 内存管理 ---------------
  146. def inject_obs_source(hwnd, exe, raw_title):
  147.     if not Data.SourceName: return
  148.     source = S.obs_get_source_by_name(Data.SourceName)
  149.     if not source: return
  150.     try:
  151.         cls_name = win32gui.GetClassName(hwnd)
  152.         window_str = f"{raw_title}:{cls_name}:{exe}"
  153.         settings = S.obs_data_create()
  154.         S.obs_data_set_string(settings, "capture_mode", "window")
  155.         S.obs_data_set_string(settings, "window", window_str)
  156.         S.obs_data_set_int(settings, "window_match_priority", 2)
  157.         S.obs_source_update(source, settings)
  158.         S.obs_data_release(settings)
  159.         print(f"[智能整理] 成功锁定游戏: {exe}")
  160.     finally:
  161.         S.obs_source_release(source)


  162. def reset_obs_source():
  163.     if not Data.SourceName: return
  164.     source = S.obs_get_source_by_name(Data.SourceName)
  165.     if not source: return
  166.     try:
  167.         settings = S.obs_data_create()
  168.         S.obs_data_set_string(settings, "capture_mode", "hotkey")
  169.         S.obs_data_set_string(settings, "window", "")
  170.         S.obs_source_update(source, settings)
  171.         S.obs_data_release(settings)
  172.         print("[智能整理] 游戏已退出,捕获源已切换至待命状态 (Hotkey)。")
  173.     finally:
  174.         S.obs_source_release(source)


  175. def check_game_process_alive():
  176.     global tracked_game_pid, tracked_game_hwnd
  177.     if tracked_game_pid and tracked_game_hwnd:
  178.         is_alive = False
  179.         try:
  180.             if psutil.Process(tracked_game_pid).is_running() and win32gui.IsWindow(tracked_game_hwnd):
  181.                 is_alive = True
  182.         except Exception: pass
  183.         
  184.         if not is_alive:
  185.             print("[智能整理] 侦测到游戏进程关闭,正在释放资源...")
  186.             tracked_game_pid, tracked_game_hwnd = None, None
  187.             if S.obs_frontend_replay_buffer_active():
  188.                 S.obs_frontend_replay_buffer_stop()
  189.             reset_obs_source()


  190. # --------------- 核心逻辑:双轨制判定 & 标题净化 ---------------
  191. def clean_title(t): return re.sub(r"[^\u4e00-\u9fffA-Za-z0-9\.\- ]+", '', t).strip()[:MAX_TITLE_LEN]


  192. def strip_suffix(title):
  193.     for rx in suffix_regexes:
  194.         title = rx.sub('', title)
  195.     return title.strip()


  196. def update_game_cache_by_hwnd(hwnd):
  197.     global last_game, tracked_game_pid, tracked_game_hwnd
  198.     try:
  199.         if not hwnd: return
  200.         pid = win32process.GetWindowThreadProcessId(hwnd)[1]
  201.         process = psutil.Process(pid)
  202.         exe = process.name().lower()
  203.         
  204.         exe_path = ""
  205.         try: exe_path = os.path.normpath(process.exe()).lower()
  206.         except psutil.AccessDenied: pass


  207.         raw = win32gui.GetWindowText(hwnd)
  208.         title = strip_suffix(clean_title(raw))


  209.         is_game = False


  210.         if exe_path:
  211.             for lib_dir in library_paths:
  212.                 if lib_dir and exe_path.startswith(lib_dir):
  213.                     is_game = True
  214.                     break


  215.         if not is_game and exe in game_list_exact:
  216.             is_game = True


  217.         if is_game:
  218.             if tracked_game_pid != pid:
  219.                 last_game = title or exe
  220.                 tracked_game_pid = pid
  221.                 tracked_game_hwnd = hwnd
  222.                 inject_obs_source(hwnd, exe, raw)
  223.                
  224.                 if not S.obs_frontend_replay_buffer_active():
  225.                     print(f"[智能整理] 侦测到有效游戏 [{last_game}],已自动开启回放。")
  226.                     S.obs_frontend_replay_buffer_start()
  227.             else:
  228.                 tracked_game_hwnd = hwnd
  229.                
  230.     except Exception:
  231.         pass


  232. # --- Windows 底层事件钩子 ---
  233. EVENT_SYSTEM_FOREGROUND = 0x0003
  234. WINEVENT_OUTOFCONTEXT = 0x0000
  235. CMPFUNC = ctypes.WINFUNCTYPE(None, wintypes.HANDLE, wintypes.DWORD, wintypes.HWND, wintypes.LONG, wintypes.LONG, wintypes.DWORD, wintypes.DWORD)


  236. _hook_thread_obj, _windows_thread_id, _is_hooking = None, None, False


  237. def foreground_change_callback(hWinEventHook, event, hwnd, idObject, idChild, dwEventThread, dwmsEventTime):
  238.     if event == EVENT_SYSTEM_FOREGROUND and hwnd: update_game_cache_by_hwnd(hwnd)


  239. _hook_proc = CMPFUNC(foreground_change_callback)


  240. def _hook_thread_worker():
  241.     global _windows_thread_id
  242.     _windows_thread_id = ctypes.windll.kernel32.GetCurrentThreadId()
  243.     user32 = ctypes.windll.user32
  244.     hook = user32.SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, 0, _hook_proc, 0, 0, WINEVENT_OUTOFCONTEXT)
  245.     msg = wintypes.MSG()
  246.     while user32.GetMessageW(ctypes.byref(msg), 0, 0, 0) != 0:
  247.         user32.TranslateMessage(ctypes.byref(msg))
  248.         user32.DispatchMessageW(ctypes.byref(msg))
  249.     user32.UnhookWinEvent(hook)


  250. def start_event_hook():
  251.     global _is_hooking, _hook_thread_obj
  252.     if not _is_hooking:
  253.         _is_hooking = True
  254.         _hook_thread_obj = threading.Thread(target=_hook_thread_worker, daemon=True)
  255.         _hook_thread_obj.start()
  256.         threading.Timer(1.0, lambda: update_game_cache_by_hwnd(win32gui.GetForegroundWindow())).start()


  257. def stop_event_hook():
  258.     global _is_hooking, _windows_thread_id
  259.     if _is_hooking and _windows_thread_id:
  260.         _is_hooking = False
  261.         ctypes.windll.user32.PostThreadMessageW(_windows_thread_id, 0x0012, 0, 0)
  262.         _windows_thread_id = None


  263. # --------------- 文件扫描与搬运 ---------------
  264. def _find_latest_file(directory: Path, ext: str) -> Path:
  265.     latest, max_time, current_time = None, 0, time.time()
  266.     for p in directory.glob(f"*.{ext}"):
  267.         if p.is_file() and p.stat().st_mtime > max_time:
  268.             max_time, latest = p.stat().st_mtime, p
  269.     if not latest or (current_time - max_time > 60):
  270.         raise FileNotFoundError('未找到刚刚生成的录制文件(或文件已过期)')
  271.     return latest


  272. def move_file_async(src: Path, dest: Path):
  273.     def job():
  274.         dest.parent.mkdir(parents=True, exist_ok=True)
  275.         for i in range(10):
  276.             try:
  277.                 try: src.replace(dest)
  278.                 except OSError: shutil.move(str(src), str(dest))
  279.                 print(f"[智能整理] 成功移动至: {dest}")
  280.                 return
  281.             except PermissionError: time.sleep(1)
  282.             except Exception as e:
  283.                 print(f"[智能整理] 移动错误: {e}")
  284.                 return
  285.         print(f"[智能整理] 移动失败,已达到最大重试次数: {src}")
  286.     threading.Thread(target=job, daemon=True).start()


  287. # --------------- 事件回调 ---------------
  288. def on_event(event):
  289.     _load_all_configs() # 热重载最新配置


  290.     if event in (S.OBS_FRONTEND_EVENT_RECORDING_STOPPED, S.OBS_FRONTEND_EVENT_REPLAY_BUFFER_SAVED):
  291.         out_dir, ext = Data.OutputDir, Data.Extension
  292.         if not (out_dir and ext and out_dir.exists()): return


  293.         try:
  294.             src_file = _find_latest_file(out_dir, ext)
  295.         except FileNotFoundError: return


  296.         raw = last_game or 'Desktop'
  297.         game = raw
  298.         
  299.         if raw and raw != 'Desktop':
  300.             if raw in rename_map:
  301.                 game = rename_map[raw]
  302.             else:
  303.                 match = next((v for k, v in rename_map.items() if k in raw), None)
  304.                 if match:
  305.                     game = match
  306.                 else:
  307.                     # 自动用等号格式记录新游戏
  308.                     rename_map[raw] = raw
  309.                     with open(_script_path() / RENAME_CFG, 'a', encoding='utf-8') as f:
  310.                         f.write(f"{raw}={raw}\n")


  311.         dest_file = out_dir / game / f"{game} - {src_file.name}"
  312.         move_file_async(src_file, dest_file)
复制代码

5、设置脚本
说明处能修改相应信息

1,录制输出文件夹:此目录为你OBS视频文件输出目录,在OBS---设置---输出---录制---录像路径这里能看到,需要保证目录相同
  • 这里是脚本需要去什么目录下移动你的视频文件,并在创建好的游戏名称文件夹下放下视频文件。如果这里和OBS设置的保存目录不相同,就会提示找不到需要移动的文件。
2,文件夹拓展名:OBS录制的视频格式,在OBS---设置---输出---录制---录像格式这里能看到,需要保证格式相同
  • 这里是脚本需要去上述目录下找到你需要移动的后缀名称为mp4的文件,如果这里和OBS设置的保存格式不相同,就会提示找不到需要移动的文件。
3、游戏捕获源名称:设置你需要脚本自动更改属性的源,举例名称为【自动游戏源】
4,游戏库目录:就是你的游戏目录位置





本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有帐号?注册

×
回复

使用道具 举报

浏览本版块需要:
1. 初阶会员或更高等级;
2. (点击此处)绑定Steam账号
您需要登录后才可以回帖 登录 | 注册

本版积分规则

欢迎发帖参与讨论 o(*≧▽≦)ツ,请注意
1. 寻求帮助或答案的帖子请发到问题互助版块,悬赏有助于问题解决的速度。发错可能失去在该板块发布主题的权限(了解更多
2. 表达观点可以,也请务必注意语气和用词,以免影响他人浏览,特别是针对其他会员的内容。如觉得违规可使用举报功能 交由管理人员处理,请勿引用对方的内容。
3. 开箱晒物交易中心游戏互鉴福利放送版块请注意额外的置顶版规。
4. 除了提问帖和交易帖以外,不确认发在哪个版块的帖子可以先发在谈天说地

  作为民间站点,自 2004 年起为广大中文 Steam 用户提供技术支持与讨论空间。历经二十余载风雨,如今已发展为国内最大的正版玩家据点。

列表模式 · · 微博 · Bilibili频道 · Steam 群组 · 贴吧 · QQ群 
Keylol 其乐 ©2004-2026 Chinese Steam User Fan Site.
Designed by Lee in Balestier, Powered by Discuz!
推荐使用 ChromeMicrosoft Edge 来浏览本站
广告投放|手机版|广州数趣信息科技有限公司 版权所有|其乐 Keylol ( 粤ICP备17068105号 )
GMT+8, 2026-6-8 14:38
快速回复 返回顶部 返回列表