ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

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()。唯一区别是它通过CheckedContinuationBool, Never把加入/取消的选择回传给日历流程。会话的唯一旋钮PurposeCameraSession只有一个配置旋钮——Purposeenum 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.swiftclosing.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.swiftNSPasteboard.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.swiftCameraCoordinatorObservable视图直接读取它因此切换 Mirror 通过 Observation 重新渲染而非手工重赋rootViewMainActor 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.swiftCameraStage共享舞台按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因为重设会重建预览连接导致图层空一帧CameraPanelNSPanelstyleMask含.borderless, .fullSizeContentView, .nonactivatingPanellevel .floating高于 palette、低于 dialogcollectionBehavior [.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.swiftcase .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 消息失败则提示 Couldnt take the phototone: .danger。拍照后面板必然关闭相机随之熄灭——命令已结束不为第二次拍摄空转。若用户想要多张照片重新触发命令即可每次都是一次全新的、瞬时的会话。小结可复用的能力边界从 docs/features/camera.md 与源码的对照中可以提炼出 Tinycast 相机模块的三个设计要领按用途付成本Purpose旋钮让预览路径不带输出节点拍照能力只在需要时实例化生命周期与面板绑定start()先稳定、stop()后于淡出配合close()统一出口相机绝无失控常驻的可能并发边界收敛全部阻塞 AVFoundation 调用收敛在私有CaptureBox内整个模块无第二 actorMainActor的CameraSession与CameraCoordinator之间保持单向、清晰的数据流。这套一个会话、两个界面的架构既支撑了启动器的完整拍照体验也以近乎零额外成本为日历的会前预览提供了同一份实时画面是 macOS 上复用 AVFoundation 会话的干净范例。【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表