上岸的鱼

心中有光,便可使整个世界升起太阳

最近在使用夸克看教程,需要频繁地暂停并跟着视频做。当前夸克是不支持通过媒体按键控制播放和暂停的,只能使用空格控制,并只有获取到焦点时才能生效。

现在需要无需切换光标或窗口,就能对另一个屏幕上的程序发送控制命令(如播放/暂停)

以 macOS 为例,使用 Hammerspoon 实现以下自动化流程:

  • 聚焦某个程序(如 Quark 网盘)
  • 向其发送空格键(控制播放/暂停)
  • 然后自动恢复原程序焦点与鼠标位置

🛠️ 工具准备

💡 实现逻辑

  1. 使用快捷键(如 Option + 空格)触发脚本
  2. 记录当前前台应用与鼠标位置
  3. 激活目标程序(Quark 网盘)并发送空格键
  4. 智能判断鼠标是否移动,决定是否恢复位置
  5. 切回原程序

📜 脚本代码

将以下代码添加到你的 ~/.hammerspoon/init.lua 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

hs.hotkey.bind({"alt"}, "space", function()

    local quarkAppName = "Quark"

    local frontApp = hs.application.frontmostApplication()

    local originalMousePos = hs.mouse.getAbsolutePosition()



    local quarkApp = hs.application.find(quarkAppName)

    if quarkApp then

        local win = quarkApp:mainWindow()

        if win then

            quarkApp:activate(true)

            win:focus()



            hs.timer.doAfter(0.2, function()

                hs.eventtap.event.newKeyEvent({}, "space", true):post()

                hs.eventtap.event.newKeyEvent({}, "space", false):post()



                hs.timer.doAfter(0.3, function()

                    if frontApp then frontApp:activate(true) end



                    -- 检测鼠标是否移动,如果没有再恢复位置

                    local currentMousePos = hs.mouse.getAbsolutePosition()

                    local dx = math.abs(currentMousePos.x - originalMousePos.x)

                    local dy = math.abs(currentMousePos.y - originalMousePos.y)

                    if dx < 5 and dy < 5 then

                        hs.mouse.setAbsolutePosition(originalMousePos)

                    end

                end)

            end)

        else

            hs.alert("未找到 Quark 窗口")

        end

    else

        hs.alert("未找到 Quark 应用")

    end

end)

更新版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
-- 监听系统的播放键(Play/Pause)
local quarkPlayKeyTap = hs.eventtap.new({hs.eventtap.event.types.systemDefined}, function(event)
local eventData = event:systemKey()

-- 只处理按下的 Play 键
if not eventData or eventData.key ~= "PLAY" or not eventData.down then
return false
end

local quarkAppName = "夸克网盘"
local quarkApp = hs.application.find(quarkAppName)

-- 如果夸克没运行,让系统继续处理播放键
if not quarkApp then
return false
end

local frontApp = hs.application.frontmostApplication()
local originalMousePos = hs.mouse.getAbsolutePosition()
local win = quarkApp:mainWindow()

-- 如果没有窗口,也放行
if not win then
return false
end

-- 激活夸克并发空格
quarkApp:activate(true)
win:focus()

hs.timer.doAfter(0.2, function()
hs.eventtap.keyStroke({}, "space")

hs.timer.doAfter(0.3, function()
if frontApp then frontApp:activate(true) end
local currentMousePos = hs.mouse.getAbsolutePosition()
if math.abs(currentMousePos.x - originalMousePos.x) < 5 and math.abs(currentMousePos.y - originalMousePos.y) < 5 then
hs.mouse.setAbsolutePosition(originalMousePos)
end
end)
end)

return true -- 阻止默认行为(避免 Music.app 被激活)
end)

quarkPlayKeyTap:start()

最终版?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
local log = hs.logger.new("🔊 播放键", "info")
local quarkAppName = "夸克网盘"

-- 全局变量,防止 GC
tap = nil
local lastTriggered = 0

local function isFrontmostAppQuark()
local frontApp = hs.application.frontmostApplication()
if not frontApp then return false end
local name = frontApp:name()
if not name then return false end
return name == quarkAppName
end

local lastFocusedWindow = nil
local lastMousePos = nil

local function sendSpaceToQuark()
local now = hs.timer.secondsSinceEpoch()
if now - lastTriggered < 1.5 then
log.i("⚠️ 操作过于频繁,忽略本次触发")
return
end
lastTriggered = now

local frontApp = hs.application.frontmostApplication()
log.i("当前前台应用: " .. (frontApp and frontApp:name() or "未知"))

lastMousePos = hs.mouse.absolutePosition()
lastFocusedWindow = hs.window.frontmostWindow()

local quarkApp = hs.application.find(quarkAppName)
if not quarkApp then
log.e("❌ 未找到 '" .. quarkAppName .. "' 应用")
return
end
log.i("'" .. quarkAppName .. "' 窗口数量: " .. tostring(#quarkApp:allWindows()))

local tryCount = 0
local maxTries = 10
local tryInterval = 0.3

local function tryActivate()
tryCount = tryCount + 1
log.i("尝试切换焦点到 '" .. quarkAppName .. "',次数:" .. tryCount)
quarkApp:activate(true)

hs.timer.doAfter(tryInterval, function()
local quarkWindows = quarkApp:visibleWindows()
if #quarkWindows > 0 then
log.i("聚焦 '" .. quarkAppName .. "' 可见窗口")
quarkWindows[1]:focus()
else
log.w("⚠️ '" .. quarkAppName .. "' 无可见窗口")
end

if isFrontmostAppQuark() then
log.i("✅ 成功切换焦点到 '" .. quarkAppName .. "',发送空格键")
hs.eventtap.keyStroke({}, "space")
hs.timer.doAfter(0.05, function()
hs.eventtap.keyStroke({}, "space")
log.i("⬇️ 空格键发送 x2")
hs.timer.doAfter(0.2, function()
if lastFocusedWindow and lastFocusedWindow:application() then
log.i("🔄 尝试恢复焦点到原应用")
local newMousePos = hs.mouse.absolutePosition()
if newMousePos.x == lastMousePos.x and newMousePos.y == lastMousePos.y then
local app = lastFocusedWindow:application()
app:activate(true)
hs.timer.doAfter(0.1, function()
if lastFocusedWindow:isVisible() then
lastFocusedWindow:focus()
log.i("✅ 焦点已恢复")
else
log.w("⚠️ 原窗口不可见")
end
end)
else
log.i("🖱️ 鼠标移动过,未恢复焦点")
end
else
log.w("⚠️ 无原焦点窗口")
end
end)
end)
else
if tryCount < maxTries then
log.w("⚠️ 焦点切换失败,第 " .. tryCount .. " 次," .. tryInterval .. " 秒后重试")
hs.timer.doAfter(tryInterval, tryActivate)
else
log.e("❌ 多次尝试切换焦点失败,放弃")
end
end
end)
end

tryActivate()
end

local function buildTap()
return hs.eventtap.new({ hs.eventtap.event.types.systemDefined }, function(event)
local data = event:systemKey()
if data then
log.i("检测系统键: key=" .. tostring(data.key) .. ", down=" .. tostring(data.down))
if data.key == "PLAY" then
log.i("🎬 捕获播放键")
sendSpaceToQuark()
return true
end
end
return false
end, { allowBubbles = true })
end

tap = buildTap()
tap:start()

-- 每分钟检查 tap 是否正常运行,否则重建
hs.timer.doEvery(60, function()
if not tap:isRunning() then
log.w("⚠️ tap 未运行,尝试重建")
if tap then tap:stop() end
tap = buildTap()
tap:start()
log.i("🔄 已重建 tap 监听器")
end
end)


最最终版

加上多窗口的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
local log = hs.logger.new("🔊 播放键", "info")
local quarkAppName = "夸克网盘"

-- 全局变量,防止 GC
tap = nil
local lastTriggered = 0

local function isFrontmostAppQuark()
local frontApp = hs.application.frontmostApplication()
if not frontApp then return false end
local name = frontApp:name()
if not name then return false end
return name == quarkAppName
end

local lastFocusedWindow = nil
local lastMousePos = nil

local function sendSpaceToQuark()
local now = hs.timer.secondsSinceEpoch()
if now - lastTriggered < 1.5 then
log.i("⚠️ 操作过于频繁,忽略本次触发")
return
end
lastTriggered = now

local frontApp = hs.application.frontmostApplication()
log.i("当前前台应用: " .. (frontApp and frontApp:name() or "未知"))

lastMousePos = hs.mouse.absolutePosition()
lastFocusedWindow = hs.window.frontmostWindow()

local quarkApp = hs.application.find(quarkAppName)
if not quarkApp then
log.e("❌ 未找到 '" .. quarkAppName .. "' 应用")
return
end
log.i("'" .. quarkAppName .. "' 窗口数量: " .. tostring(#quarkApp:allWindows()))

local tryCount = 0
local maxTries = 10
local tryInterval = 0.3

local function tryActivate()
tryCount = tryCount + 1
log.i("尝试切换焦点到 '" .. quarkAppName .. "',次数:" .. tryCount)
quarkApp:activate(true)

hs.timer.doAfter(tryInterval, function()
local quarkWindows = quarkApp:visibleWindows()
if #quarkWindows > 0 then
-- 优先寻找标题包含“视频”字样的窗口
local targetWindow = nil
for _, win in ipairs(quarkWindows) do
local title = win:title() or ""
log.i("检查窗口标题: " .. title)
if title:match("视频") or title:match("播放") or title:match("%.mp4") then
targetWindow = win
break
end
end
if targetWindow then
log.i("🎯 选择播放窗口: " .. (targetWindow:title() or "无标题"))
targetWindow:focus()
else
log.w("⚠️ 未找到播放窗口,聚焦第一个窗口")
quarkWindows[1]:focus()
end
else
log.w("⚠️ '" .. quarkAppName .. "' 无可见窗口")
end

if isFrontmostAppQuark() then
log.i("✅ 成功切换焦点到 '" .. quarkAppName .. "',发送空格键")
hs.eventtap.keyStroke({}, "space")
hs.timer.doAfter(0.05, function()
hs.eventtap.keyStroke({}, "space")
log.i("⬇️ 空格键发送 x2")
hs.timer.doAfter(0.2, function()
if lastFocusedWindow and lastFocusedWindow:application() then
log.i("🔄 尝试恢复焦点到原应用")
local newMousePos = hs.mouse.absolutePosition()
if newMousePos.x == lastMousePos.x and newMousePos.y == lastMousePos.y then
local app = lastFocusedWindow:application()
app:activate(true)
hs.timer.doAfter(0.1, function()
if lastFocusedWindow:isVisible() then
lastFocusedWindow:focus()
log.i("✅ 焦点已恢复")
else
log.w("⚠️ 原窗口不可见")
end
end)
else
log.i("🖱️ 鼠标移动过,未恢复焦点")
end
else
log.w("⚠️ 无原焦点窗口")
end
end)
end)
else
if tryCount < maxTries then
log.w("⚠️ 焦点切换失败,第 " .. tryCount .. " 次," .. tryInterval .. " 秒后重试")
hs.timer.doAfter(tryInterval, tryActivate)
else
log.e("❌ 多次尝试切换焦点失败,放弃")
end
end
end)
end

tryActivate()
end

local function buildTap()
return hs.eventtap.new({ hs.eventtap.event.types.systemDefined }, function(event)
local data = event:systemKey()
if data then
log.i("检测系统键: key=" .. tostring(data.key) .. ", down=" .. tostring(data.down))
if data.key == "PLAY" then
log.i("🎬 捕获播放键")
sendSpaceToQuark()
return true
end
end
return false
end, { allowBubbles = true })
end

tap = buildTap()
tap:start()

-- 每分钟检查 tap 是否正常运行,否则重建
hs.timer.doEvery(60, function()
if not tap:isRunning() then
log.w("⚠️ tap 未运行,尝试重建")
if tap then tap:stop() end
tap = buildTap()
tap:start()
log.i("🔄 已重建 tap 监听器")
end
end)

✅ 使用说明

  1. 启动 Quark 网盘并加载播放界面
  2. 回到其他应用继续工作(如浏览器、VS Code)
  3. 按下 Option + 空格
       - 自动切换至 Quark
       - 空格键触发播放/暂停
       - 稍后自动回到原应用
       - 如果鼠标没动,还原到原本的鼠标位置

🔧 技术细节优化

  • 使用 hs.eventtap.event.newKeyEvent(),更底层模拟空格键,更兼容 Electron 应用(如 Quark 网盘)
  • app:activate(true) + win:focus() 保证窗口成为第一响应者
  • 鼠标恢复前做位置差判断,避免“跳回”造成干扰

🧾 总结

通过 Hammerspoon 脚本,可以在 macOS 上无感控制任意 App。即使是在多屏使用场景,也能做到。

不打断当前任务,一键遥控另一个程序行为。
自动化提升的不只是效率,更是专注力。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108

#!/bin/bash

# ====== 配置路径 ======
source_folder="$HOME/Library/Obsidian/1-blog"
destination_folder="$HOME/blog/source/_posts"
image_folder="$HOME/Library/blog/source/images"
hexo_root="$HOME/Library/blog"
log_file="$hexo_root/deploy.log"

# ====== 参数处理 ======
PREVIEW=false
BACKUP=false
for arg in "$@"; do
case $arg in
--preview) PREVIEW=true ;;
--backup) BACKUP=true ;;
esac
done

# ====== 日志函数 ======
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a "$log_file"
}

# ====== 检查 SSH 连接 ======
log "🔐 检查 GitHub SSH 连接..."
if ! ssh -T [email protected] 2>&1 | grep -q "successfully authenticated"; then
log "❌ SSH 未配置或连接失败!请先配置 GitHub SSH key:https://github.com/settings/keys"
exit 1
fi
log "✅ SSH 连接正常"

# ====== 可选备份 ======
if $BACKUP; then
backup_zip="$HOME/Desktop/1-blog-backup_$(date +'%Y%m%d-%H%M%S').zip"
log "🗃️ 正在备份 Obsidian 博客目录..."
zip -r "$backup_zip" "$source_folder" >/dev/null
log "✅ 已备份到桌面:$backup_zip"
fi

# ====== 确保目录存在 ======
mkdir -p "$destination_folder"
mkdir -p "$image_folder"

# ====== 增量同步 Markdown 和图片文件 ======
log "📥 增量同步 Markdown 和图片..."
rsync -av --include="*/" \
--include="*.md" \
--include="*.png" \
--include="*.jpg" \
--include="*.jpeg" \
--include="*.gif" \
--include="*.svg" \
--include="*.webp" \
--exclude="*" \
"$source_folder"/ "$destination_folder"/

# ====== 替换图片语法 & 拷贝图片 ======
log "🛠 替换 Obsidian 图片语法,并复制图片..."

find "$destination_folder" -name "*.md" | while read -r mdfile; do
rel_md_path="${mdfile#$destination_folder/}"
original_md="$source_folder/$rel_md_path"
original_md_dir=$(dirname "$original_md")

# 查找 Obsidian 图片语法
perl -nle 'print $1 while /\!\[\[\s*(.*?)\s*\]\]/g' "$mdfile" | while read -r relimg; do
imgname=$(basename "$relimg")
src_img="$original_md_dir/$relimg"
dest_img="$image_folder/$imgname"

if [[ -f "$src_img" ]]; then
# 增量复制
if [[ ! -f "$dest_img" ]]; then
cp "$src_img" "$dest_img"
log "🖼 已复制图片: $src_img -> $dest_img"
fi
else
log "⚠️ 图片未找到: $src_img"
fi
done

# 替换 Obsidian 语法为 Hexo 路径
perl -i -pe 's/\!\[\[\s*(.*?)\s*\]\]/![]\(\/images\/\1\)/g' "$mdfile"
perl -i -pe 's/\/images\/.*\/([^\/]+\.(png|jpg|jpeg|gif|svg|webp))/\/images\/$1/g' "$mdfile"
done

log "✅ 图片路径修复完成"

# ====== Hexo 构建或预览 ======
cd "$hexo_root" || { log "❌ Hexo 根目录不存在"; exit 1; }

log "🧹 Hexo 清理 & 生成中..."
hexo clean && hexo generate

if $PREVIEW; then
log "🌐 启动本地预览..."
hexo server &
sleep 2
open "http://localhost:4000"
log "✅ 浏览器已打开本地预览"
else
log "🚀 正在部署 Hexo 博客..."
hexo deploy && log "✅ 部署完成"
fi


Unreal Engine 5:Nanite 与 Fracture 不兼容问题总结

在使用 Chaos Fracture 进行物体破坏时,如果出现以下问题:

  • 看不到碎片(Exploded View 无效)
  • 模拟或运行时无法破碎
  • 碎片无法参与物理模拟

很可能是因为你的模型启用了 Nanite

❗ 问题原因

Nanite 不支持运行时的网格拓扑变形,和 Chaos Fracture 系统不兼容。> “Nanite meshes cannot be fractured as the data representation is not compatible with Chaos destruction.” —— 官方论坛讨论原帖

✅ 解决方法

  1. 找到原始 Static Mesh
  2. 右键关闭 Nanite
  3. 保存并重新创建 Geometry Collection

📌 提示

  • 启用了 Nanite 的模型在资源图标上有绿色闪电标志 ⚡
  • 关闭 Nanite 后必须重新创建 GC,旧的无效

🧠 总结表

项目 是否支持
Nanite 支持高效渲染 ✅ 支持
Nanite Mesh 支持 Fracture ❌ 不支持
正确做法:关闭 Nanite 再破碎 ✅ 推荐

记住一句话:要做破碎,先关 Nanite

🪴 UE5 草不显示问题排查记录(Mac mini + 5.5.4)

最近在用 Unreal Engine 5.5.4 做地形,想用 LandscapeGrassOutput 实现图层驱动自动刷草。
材质节点、LandscapeGrassType、图层权重、Layer Info 都设置好了,图层也确实涂上了。

但草就是不显示。

我试了常规操作:

  • 材质和图层名一致 ✅
  • GrassType 绑定 ✅
  • 图层有绘制 ✅
  • 控制台尝试过:
    r.Grass.Enable 1  
    grass.flushcache  
    dumpgrassmaps
    

全都没效果。

💡 真正的原因
后来发现,是因为引擎当前处于 “Low” 性能模式。

在 Low 模式下,Unreal 会自动把 r.Grass.Enable 设为 0,
即使控制台强制开启也会被下一帧覆盖。

我把引擎切到 High 或 Epic 模式后,草立刻刷出来了。

💻 补充:Mac 上使用 UE 的一些坑
我是在 Mac mini 上开发的,草系统这种依赖 GPU 实例化的功能
在 macOS + Metal 渲染器下表现经常不稳定。

一些注意事项:

GrassOutput 有时完全无效(尤其配合 Nanite Mesh)

r.Grass.Enable 可能被忽略或不响应

PCG 效果更可靠(推荐用于 Mac 项目)

✅ 总结
如果你遇到 GrassOutput 不显示的问题:

❓ 检查是不是 Low 模式

✅ 设置 r.Grass.Enable=1(最好通过 .ini)

⚠️ 在 Mac 上开发需特别小心 Grass 系统兼容性

一句话总结:
UE 的 Low 模式默认禁用草系统,macOS 上更容易中招。别急着改材质,先调高性能档位。

0%