news 2026/9/20 6:07:39

Tinycast 相机模块深度解析:从启动器命令到剪贴板照片的完整链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Tinycast 相机模块深度解析:从启动器命令到剪贴板照片的完整链路

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不添加任何输出。这是最便宜的路径——只有输入、没有输出节点;
  • .captureOpen 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.cancelkVK_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.AVCaptureSessionSendable,只有CaptureBox跨越并发边界

阻塞调用——startRunningstopRunning、设备切换的 begin/commit——都在Task.detached中执行,藏在一个私有的@unchecked Sendable盒子后面:

private struct CaptureBox: @unchecked Sendable { let session: AVCaptureSession var input: AVCaptureDeviceInput? }

该盒子私有于 CameraSession.swift,整个相机模块没有第二个 actorPhotoCapture同样以@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.") }

    CameraFeedNSViewRepresentable,采用layer-hosting(先设view.layer = previewwantsLayer = true),确保 AppKit 不会用 layer-backed 视图替换掉预览层;updateNSView中只有在 session 真正变化时才重设preview.session,因为"重设会重建预览连接,导致图层空一帧";

  • CameraPanelNSPanelstyleMask.borderless, .fullSizeContentView, .nonactivatingPanellevel = .floating("高于 palette、低于 dialog"),collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary],并禁用 AppKit 自带窗口动画(animationBehavior = .none),用fadeIn/fadeOut替代。定位用centerOnCursorScreen():取光标所在屏幕的visibleFrame,水平居中、垂直方向再上抬 8%(centerLift: CGFloat = 0.08),与对话框的"抬升"保持一致;
  • CameraButtonDialogButton的孪生体,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.DiscoverySessiondeviceTypes[.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)是"命令完成即收尾"哲学的最好注脚:

  1. 校验feed.live(非 live 直接返回);
  2. 快照当前mirrored状态(避免任务执行期间用户再翻转镜像造成取景与输出不一致);
  3. await session.capturePhoto(mirrored:)在后台完成"拍摄 → JPEG 解码 → PNG 重编码";
  4. close()——面板淡出、相机熄灭;
  5. 写入NSPasteboard.general,并弹出 "Photo copied" 消息;失败则提示 "Couldn't take the photo"(tone: .danger)。

拍照后面板必然关闭,相机随之熄灭——命令已结束,不为第二次拍摄空转。若用户想要多张照片,重新触发命令即可,每次都是一次全新的、瞬时的会话。

小结:可复用的能力边界

从 docs/features/camera.md 与源码的对照中,可以提炼出 Tinycast 相机模块的三个设计要领:

  1. 按用途付成本Purpose旋钮让预览路径不带输出节点,拍照能力只在需要时实例化;
  2. 生命周期与面板绑定start()先稳定、stop()后于淡出,配合close()统一出口,相机绝无失控常驻的可能;
  3. 并发边界收敛:全部阻塞 AVFoundation 调用收敛在私有CaptureBox内,整个模块无第二 actor,@MainActorCameraSessionCameraCoordinator之间保持单向、清晰的数据流。

这套"一个会话、两个界面"的架构,既支撑了启动器的完整拍照体验,也以近乎零额外成本为日历的会前预览提供了同一份实时画面,是 macOS 上复用 AVFoundation 会话的干净范例。

【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 6:07:07

VS Code 配置 Claude Code 完整教程:环境安装、settings.json 与 API 接入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 6:07:03

硅光与纳米电子学集成:光电协同芯片的设计与封装实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 6:04:24

虚拟电厂中电转气与碳捕集协同优化调度的Matlab+Cplex实现

拿到这种标题的项目&#xff0c;我第一反应是&#xff1a;这是典型的电力系统优化调度方向的论文级题目&#xff0c;大概率是研究生课题或者毕业设计。凡是碰过虚拟电厂、综合能源、碳捕集这类题目的人都知道&#xff0c;最耗时间的不是模型本身&#xff0c;而是“模型写出来跑…

作者头像 李华