## 长图 docs/poster.png:六段式,一张图讲完这个工具是什么。 生成脚本 Resources/make-poster.swift,改文案或调顺序改那个数组即可。 修了个坑:NSImage.size 返回的是**点**不是像素,这批截图是 2x 抓的, 1880px 的图它报 940,于是大图被当小图处理,第一版只占了半幅。 ## 截图 scripts/shoot-screenshots.sh:用 HUB_DEMO=1 + HUB_ISLAND_STATE 把形态钉死, 自动截 + 自动裁掉透明留白,五种形态全自动。 之前那批是改名前截的,岛头还写着旧名。重截之后顺带修好三件事: - 演示工作区路径 claude-hub-demo → vibe-foreman-demo - 新图带上了「在问你」横幅和状态徽章,比旧图更能说明问题 - 删掉 04-main-window.png:验收页本来就带主窗口侧边栏,一张顶两张, 而且那张的标题栏是旧名 两个配套小工具: - scripts/find-window.swift 拿窗口 id 给 screencapture -l 用。 CGWindowList 报的是显示名不是可执行名,写死 ClaudeHub 一个都找不到。 - scripts/crop-alpha.swift 裁到不透明内容的外接矩形。岛画在 640×720 的 透明容器窗口里,不裁的话拼进长图就是一大块空白。 截图脚本踩了 NOTES.md 里记过的那个 bash 坑:变量后面紧跟中文标点 (`$state)`)会被算进变量名,set -u 下直接报未定义。加花括号。 窗口探测改成轮询而不是固定 sleep —— 实测 4 秒对 hover 够、对 expanded 不够。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
72 lines
2.6 KiB
Swift
72 lines
2.6 KiB
Swift
import CoreGraphics
|
||
import Foundation
|
||
import ImageIO
|
||
|
||
// 把 PNG 裁到「不透明内容」的外接矩形。
|
||
//
|
||
// 岛是画在一个 640×720 的透明容器窗口里的(这样形态变化时不用改窗口尺寸),
|
||
// 直接 `screencapture -l` 截下来四周有大片透明。不裁的话拼进长图里
|
||
// 就是一块巨大的空白,看起来像排版塌了。
|
||
//
|
||
// 阈值取 20 而不是 0:岛有一圈很淡的外发光,按 alpha>0 裁会把那圈
|
||
// 几乎看不见的雾也算进来,边距忽大忽小、每张都不一样。
|
||
|
||
let args = Array(CommandLine.arguments.dropFirst())
|
||
guard args.count >= 1 else {
|
||
FileHandle.standardError.write(Data("用法:crop-alpha.swift <输入.png> [输出.png]\n".utf8))
|
||
exit(1)
|
||
}
|
||
let input = URL(fileURLWithPath: args[0])
|
||
let output = URL(fileURLWithPath: args.count >= 2 ? args[1] : args[0])
|
||
|
||
guard let source = CGImageSourceCreateWithURL(input as CFURL, nil),
|
||
let image = CGImageSourceCreateImageAtIndex(source, 0, nil)
|
||
else { exit(1) }
|
||
|
||
let width = image.width
|
||
let height = image.height
|
||
|
||
// 重画一遍进已知布局的缓冲区 —— 直接读 image.dataProvider 拿到的字节序
|
||
// 依赖源图的 bitmapInfo,PNG 的排列方式不止一种,猜错就裁在莫名其妙的地方。
|
||
var pixels = [UInt8](repeating: 0, count: width * height * 4)
|
||
guard let ctx = pixels.withUnsafeMutableBytes({ raw -> CGContext? in
|
||
CGContext(
|
||
data: raw.baseAddress, width: width, height: height,
|
||
bitsPerComponent: 8, bytesPerRow: width * 4,
|
||
space: CGColorSpaceCreateDeviceRGB(),
|
||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||
)
|
||
}) else { exit(1) }
|
||
ctx.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||
|
||
let threshold: UInt8 = 20
|
||
var minX = width, minY = height, maxX = -1, maxY = -1
|
||
|
||
for y in 0..<height {
|
||
let row = y * width * 4
|
||
for x in 0..<width where pixels[row + x * 4 + 3] > threshold {
|
||
if x < minX { minX = x }
|
||
if x > maxX { maxX = x }
|
||
if y < minY { minY = y }
|
||
if y > maxY { maxY = y }
|
||
}
|
||
}
|
||
|
||
guard maxX >= minX, maxY >= minY else {
|
||
FileHandle.standardError.write(Data("整张都是透明的\n".utf8))
|
||
exit(1)
|
||
}
|
||
|
||
let rect = CGRect(
|
||
x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1
|
||
)
|
||
guard let cropped = image.cropping(to: rect),
|
||
let dest = CGImageDestinationCreateWithURL(
|
||
output as CFURL, "public.png" as CFString, 1, nil
|
||
)
|
||
else { exit(1) }
|
||
|
||
CGImageDestinationAddImage(dest, cropped, nil)
|
||
guard CGImageDestinationFinalize(dest) else { exit(1) }
|
||
print("\(width)×\(height) → \(Int(rect.width))×\(Int(rect.height))")
|