Tinycast 相机模块深度解析:从启动器命令到剪贴板照片的完整链路
【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast
导读
Tinycast 的相机功能是"一个会话、两个界面"的典型设计:Open Camera启动器命令提供实时预览、镜像切换、多摄像头切换与拍照直入剪贴板的能力;日历的会前预览则复用同一套会话、面板与舞台。本文以 docs/features/camera.md 为骨架,结合 CameraSession.swift、CameraCoordinator.swift 等源码实现,逐条拆解其设计不变量(Invariants)、装配方式与可达路径,让你掌握 Tinycast 如何在 macOS 上以最克制的资源占用完成一次"预览—拍照—入剪贴板"的完整闭环。
一个会话,两个界面
原文开篇即点明核心架构:一套相机会话(CameraSession)、两个消费界面。
Open Camera启动器命令:独立面板,提供实时预览、镜像(Mirror)、切换摄像头(Switch Camera)、拍照(Take Photo)四个能力;- 日历会前预览:在会议开始前的"检查自己"(check-yourself)面板中展示实时画面,仅保留会议专属的 Join/Cancel 控制器与页脚,其余全部复用。
这份"复用"不是复制代码,而是共享三个关键组件:
| 组件 | 文件 | 职责 |
|---|---|---|
| 会话与设备管理 | Service/CameraSession.swift | 权限请求、启动/停止、设备循环、拍照、CaptureBox并发边界 |
| 独立面板生命周期 | UI/CameraCoordinator.swift | 面板生命周期、镜像状态、剪贴板写入 |
| 独立表面 | UI/CameraView.swift | 独立表面:舞台 + 控制页脚 |
| 共享舞台 | UI/CameraStage.swift | 承载预览层,或解释"为什么没有画面" |
| 共享面板 | UI/CameraPanel.swift | 无边框面板:↵ / Esc 快捷键、光标所在屏居中 |
| 共享胶囊按钮 | UI/CameraButton.swift | 两个页脚共用的胶囊按钮 |
从源码结构看,日历侧的 CameraPreviewController.swift 与独立命令的CameraCoordinator是"孪生"关系:它同样持有CameraSession()(默认.preview用途)、同样在present(meeting:now:)中先await session.start()再show(...),同样在fadeOut完成回调中调用session.stop()。唯一区别是它通过CheckedContinuation<Bool, Never>把"加入/取消"的选择回传给日历流程。
会话的"唯一旋钮":Purpose
CameraSession只有一个配置旋钮——Purpose:
enum Purpose { case preview case capture }它直接决定会话的"成本":
.preview(日历会前预览采用):sessionPreset为.medium,不添加任何输出。这是最便宜的路径——只有输入、没有输出节点;.capture(Open Camera采用):预设提升到.photo,并额外添加AVCapturePhotoOutput。这个输出节点就是"能拍照"的全部代价,且只由真正需要拍照的命令支付。
对应实现见 CameraSession.swift 的configure():
let capture = AVCaptureSession() capture.sessionPreset = purpose == .capture ? .photo : .medium guard capture.canAddInput(input) else { return nil } capture.addInput(input) self.device = device guard purpose == .capture else { return capture } let output = AVCapturePhotoOutput() guard capture.canAddOutput(output) else { return capture } capture.addOutput(output) photoOutput = output return capture从源码结构看,这种"按用途增删输出节点"的设计让日历预览零额外负担——它永远不会为从未按下的快门买单。
六条设计不变量(Invariants)与源码印证
1. 命令不运行,相机就绝不运行
CameraCoordinator在 AppCore.swift 上是lazy声明的:
@ObservationIgnored private(set) lazy var cameraCoordinator = CameraCoordinator(core: self)而CameraSession.init只保存Purpose,不触碰任何设备、发现会话或 TCC 读取:
init(_ purpose: Purpose = .preview) { self.purpose = purpose }权限弹窗由start()触发的Permissions.requestCameraAccess()抛出(见 CameraSession.swift),即由请求它的手势触发,绝不在启动时触发。
2. 相机先"稳定"再开面板,面板关闭后相机才停止
start()的流程是:先解析权限 → 阻塞等待startRunning完成 → 才把稳定的Feed交出去:
func start() async -> Feed { var access = Permissions.cameraAccess() if access == .notDetermined { _ = await Permissions.requestCameraAccess() access = Permissions.cameraAccess() } guard access == .granted else { return .denied } guard let capture = capture ?? configure() else { return .noCamera } self.capture = capture guard !capture.isRunning else { return .live(capture) } let box = CaptureBox(session: capture) await Task.detached { box.session.startRunning() }.value return .live(capture) }这保证用户看到的第一帧是实时视频,而不是被换掉的舞台;TCC 弹窗也永远不会抢走已弹出面板的键。stop()则从淡出动画(fadeOut)的完成回调中执行,见 CameraCoordinator.swift:
closing.fadeOut(duration: Theme.Duration.exit) { [weak self] in guard let self, !opening else { return } session.stop() }注释明确说明意图:"The camera goes with the panel, not before it: tearing it down mid-fade blanks the feed."——相机指示灯永远不会比面板活得更久,也绝不会在可见面板之下被拆除。guard !opening的存在还防止了淡出期间新面板已接管相机的情况。
3. Esc、点击外部、拍照——殊途同归于close()
所有退出路径都收敛到CameraCoordinator.close(),它先摘下面板、再在淡出后停会话:
func close() { guard let closing = panel else { return } panel = nil closing.delegate = nil closing.onKey = nil closing.fadeOut(duration: Theme.Duration.exit) { [weak self] in guard let self, !opening else { return } session.stop() } }- Esc / 回车:由 CameraPanel.swift 的
sendEvent拦截——kVK_Escape→.cancel,kVK_Return/kVK_ANSI_KeypadEnter→.primary,回调分别对应close()与takePhoto(); - 点击外部:由
windowDidResignKey覆盖,见 CameraCoordinator.swift; - 拍照后:命令已完成,相机随面板一起熄灭,绝不为第二张照片空转(见下文"拍照即结束")。
4. 预览与照片"同镜像"
镜像(Mirror)通过连接(connection)上的isVideoMirrored实现——预览层的连接和拍照输出在拍摄时刻分别设置,而绝不用视图上的scaleEffect(那会把"不是用户取景内容"的照片交还给人)。参见 CameraSession.swift 的capturePhoto(mirrored:):
if let connection = photoOutput.connection(with: .video), connection.isVideoMirroringSupported { connection.automaticallyAdjustsVideoMirroring = false connection.isVideoMirrored = mirrored }automaticallyAdjustsVideoMirroring = false是关键:它关掉系统对前置摄像头自动镜像的行为,让照片与用户在取景框里看到的一致。
5. 照片以 PNG 到达剪贴板
相机自身的编码(JPEG)在主线程之外被解码并重新编码为 PNG,因为ClipboardManager只记录.png与.tiff两种格式:
guard let encoded = await capture.take(from: photoOutput) else { return nil } return await Task.detached { NSBitmapImageRep(data: encoded)?.representation(using: .png, properties: [:]) }.value只有以 PNG 写入,剪贴板历史(Clipboard History)才会把它识别为图片条目,而不是一串不可索引的二进制。写入动作发生在 CameraCoordinator.swift:
NSPasteboard.general.clearContents() NSPasteboard.general.setData(png, forType: .png) core.showMessage("Photo copied")6.AVCaptureSession不Sendable,只有CaptureBox跨越并发边界
阻塞调用——startRunning、stopRunning、设备切换的 begin/commit——都在Task.detached中执行,藏在一个私有的@unchecked Sendable盒子后面:
private struct CaptureBox: @unchecked Sendable { let session: AVCaptureSession var input: AVCaptureDeviceInput? }该盒子私有于 CameraSession.swift,整个相机模块没有第二个 actor。PhotoCapture同样以@unchecked Sendable承载CheckedContinuation——continuation 在主线程写入、在 AVFoundation 队列上读取一次。
装配方式:谁持有什么
原文的"装配表"在源码中一一对应:
CameraSession:权限、启停、设备循环、拍照、并发盒子,全部封装在 Service/CameraSession.swift;CameraCoordinator:@Observable,视图直接读取它,因此切换 Mirror 通过 Observation 重新渲染,而非手工重赋rootView:@MainActor @Observable final class CameraCoordinator: NSObject, NSWindowDelegate { private(set) var feed: CameraSession.Feed = .noCamera private(set) var canSwitchCamera = false var mirrored = true @ObservationIgnored private let session = CameraSession(.capture) }CameraView:独立表面,舞台 + 页脚。页脚根据feed是否为.live动态渲染控制按钮(未 live 时只有 Close),见 UI/CameraView.swift;CameraStage:共享舞台,按Feed三态渲染:switch feed { case .live(let capture): CameraFeed(session: capture, mirrored: mirrored) case .denied: unavailable("Tinycast has no access to the camera.") case .noCamera: unavailable("No camera on this Mac.") }CameraFeed是NSViewRepresentable,采用layer-hosting(先设view.layer = preview再wantsLayer = true),确保 AppKit 不会用 layer-backed 视图替换掉预览层;updateNSView中只有在 session 真正变化时才重设preview.session,因为"重设会重建预览连接,导致图层空一帧";CameraPanel:NSPanel,styleMask含.borderless, .fullSizeContentView, .nonactivatingPanel,level = .floating("高于 palette、低于 dialog"),collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary],并禁用 AppKit 自带窗口动画(animationBehavior = .none),用fadeIn/fadeOut替代。定位用centerOnCursorScreen():取光标所在屏幕的visibleFrame,水平居中、垂直方向再上抬 8%(centerLift: CGFloat = 0.08),与对话框的"抬升"保持一致;CameraButton:DialogButton的孪生体,secondary强调色同时表达"关闭/关闭中"——镜像开关通过主/次强调色切换呈现"开/关"状态,而非新增一套样式,见 UI/CameraButton.swift。
镜像状态的归属:为什么不在设置里
mirrored挂在CameraCoordinator上,而不是AppSettings。原文给出的理由非常务实:它是"为本次启动记住"的显示偏好——重开命令时保留你满意的取景方向;但一个不授予任何权限的显示偏好,不值得占一个设置键、也不值得在备份里占一行。这是 Tinycast "设置项宁缺毋滥"原则在相机模块的体现。
摄像头切换:会话不中断
切换摄像头时,只在一次beginConfiguration/commitConfiguration内替换输入,会话持续运行,因此舞台永远不会黑屏:
func switchToNextDevice() async { let devices = Self.devices guard devices.count > 1, let capture, let device else { return } let index = devices.firstIndex(of: device) ?? devices.count - 1 let next = devices[(index + 1) % devices.count] guard let input = try? AVCaptureDeviceInput(device: next) else { return } let box = CaptureBox(session: capture, input: input) await Task.detached { box.session.beginConfiguration() for existing in box.session.inputs { box.session.removeInput(existing) } if let input = box.input, box.session.canAddInput(input) { box.session.addInput(input) } box.session.commitConfiguration() }.value self.device = next }几个实现细节值得注意:
- 设备发现走
AVCaptureDevice.DiscoverySession,deviceTypes为[.builtInWideAngleCamera, .external, .continuityCamera](内置广角、外接、连续互通相机),见 CameraSession.swift; - 当前设备若已不在发现列表(例如被拔出),索引回退到列表末尾再取模,即"回绕到第一个而不是卡死";
- "Switch Camera" 按钮只有在
hasMultipleDevices(发现会话找到多于一台设备)时才出现在 CameraView.swift 的页脚中,单摄像头 Mac 上界面保持干净。
可达路径:一条启动器命令,零配置
Open Camera在 CommandID.swift 中注册为:
case openCamera = "command:open-camera"其分发点在 LauncherCoordinator.swift:
case .openCamera: dismissPalette() Task { await core.cameraCoordinator.show() }这意味着它像任何命令一样支持别名(alias)和全局快捷键,并且归属Command类别。它没有设置面板、没有启用开关——正如文档所言:"there is nothing to configure and nothing to leave running"(没有可配置项,也没有常驻运行的东西)。打开即用、用完即走,整个功能面被压缩到一条命令和它瞬时生命周期内。
show()中的opening布尔标记还承担了防重入职责:guard !opening, panel == nil else { return }保证即使快捷键连按触发多次,也不会堆叠出多个面板。
拍照即结束:一次性的快门
takePhoto()的完整链路(见 CameraCoordinator.swift)是"命令完成即收尾"哲学的最好注脚:
- 校验
feed为.live(非 live 直接返回); - 快照当前
mirrored状态(避免任务执行期间用户再翻转镜像造成取景与输出不一致); await session.capturePhoto(mirrored:)在后台完成"拍摄 → JPEG 解码 → PNG 重编码";- 先
close()——面板淡出、相机熄灭; - 写入
NSPasteboard.general,并弹出 "Photo copied" 消息;失败则提示 "Couldn't take the photo"(tone: .danger)。
拍照后面板必然关闭,相机随之熄灭——命令已结束,不为第二次拍摄空转。若用户想要多张照片,重新触发命令即可,每次都是一次全新的、瞬时的会话。
小结:可复用的能力边界
从 docs/features/camera.md 与源码的对照中,可以提炼出 Tinycast 相机模块的三个设计要领:
- 按用途付成本:
Purpose旋钮让预览路径不带输出节点,拍照能力只在需要时实例化; - 生命周期与面板绑定:
start()先稳定、stop()后于淡出,配合close()统一出口,相机绝无失控常驻的可能; - 并发边界收敛:全部阻塞 AVFoundation 调用收敛在私有
CaptureBox内,整个模块无第二 actor,@MainActor的CameraSession与CameraCoordinator之间保持单向、清晰的数据流。
这套"一个会话、两个界面"的架构,既支撑了启动器的完整拍照体验,也以近乎零额外成本为日历的会前预览提供了同一份实时画面,是 macOS 上复用 AVFoundation 会话的干净范例。
【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考