mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
camera: Host-side virtual camera pipe (vphone-cli)
Mac-side feeder for the virtual camera. The host generates frames
(test pattern or video file) and pushes BGRA buffers over vsock to
the guest daemon vphoned, which mirrors them into the shm region
that the in-VM libvcamcaptured + libcamfix consume.
VPhoneCameraServer @MainActor vsock client to vphoned port
1338. Reconnects on transport failure
so the host can survive guest reboots.
VPhoneFrameProducer Test-pattern (hue-rolling gradient) and
VideoFile producers; emit BGRA at the
session's requested width/height/fps.
VPhoneMenuCamera Camera menu: pick source, start/stop
the feeder, show stream status.
VPhoneMenuController/Connect/AppDelegate
Wire the menu into the existing menu
bar and start the server alongside the
other vsock services at VM boot.
End state: with a vphoned listener on the guest side, host-generated
BGRA frames appear in the shm region the camera daemon reads from.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
committed by
zqxwce
co-authored by
Claude Opus 4.7
parent
9bb5b87279
commit
0223ad139c
@@ -13,6 +13,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var appWindowController: VPhoneAppWindowController?
|
||||
private var locationProvider: VPhoneLocationProvider?
|
||||
private var hostControl: VPhoneHostControl?
|
||||
private var cameraServer: VPhoneCameraServer?
|
||||
private var sigintSource: DispatchSourceSignal?
|
||||
private var didAttemptAutoInstall = false
|
||||
|
||||
@@ -88,8 +89,12 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
||||
let provider = VPhoneLocationProvider(control: control)
|
||||
locationProvider = provider
|
||||
|
||||
let camServer = VPhoneCameraServer()
|
||||
cameraServer = camServer
|
||||
|
||||
if let device = vm.virtualMachine.socketDevices.first as? VZVirtioSocketDevice {
|
||||
control.connect(device: device)
|
||||
camServer.connect(device: device)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +140,14 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
||||
if let provider = locationProvider {
|
||||
mc.locationProvider = provider
|
||||
}
|
||||
if let camServer = cameraServer {
|
||||
mc.cameraServer = camServer
|
||||
camServer.onConnectionStateChange = { [weak mc] connected in
|
||||
Task { @MainActor in
|
||||
mc?.updateCameraConnectionState(connected: connected)
|
||||
}
|
||||
}
|
||||
}
|
||||
let recorder = VPhoneScreenRecorder()
|
||||
mc.screenRecorder = recorder
|
||||
menuController = mc
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
/// Host-side virtual-camera server.
|
||||
///
|
||||
/// Opens a vsock connection to the guest on port 1338 (separate from the
|
||||
/// vphoned control channel on 1337) and pushes raw BGRA frames at a fixed
|
||||
/// rate. The guest counterpart (a libvcamcaptured-attached receiver inside
|
||||
/// cameracaptured) ferries those frames into the AVF capture pipeline.
|
||||
///
|
||||
/// Wire format (one frame, length-prefixed):
|
||||
/// uint32 LE total_payload_length
|
||||
/// uint32 LE header_json_length
|
||||
/// bytes header JSON (UTF-8), keys: w, h, bpr, fmt, ts
|
||||
/// bytes raw pixel data, exactly `bpr * h` bytes
|
||||
///
|
||||
/// The header carries the format so the receiver doesn't have to
|
||||
/// assume; for now the producer fixes width/height/bpr/fmt at
|
||||
/// 1280x720 BGRA. Future producers may emit different formats.
|
||||
@MainActor
|
||||
final class VPhoneCameraServer {
|
||||
enum SourceKind: String {
|
||||
case off
|
||||
case testPattern
|
||||
case videoFile // .mov / .mp4 / .m4v via AVAssetReader
|
||||
}
|
||||
|
||||
nonisolated static let vsockPort: UInt32 = 1338
|
||||
nonisolated static let defaultWidth: Int = 1280
|
||||
nonisolated static let defaultHeight: Int = 720
|
||||
nonisolated static let defaultFPS: Double = 30.0
|
||||
nonisolated static let pixelFormat: UInt32 = 0x4247_5241 // 'BGRA' — kCMPixelFormat_32BGRA
|
||||
|
||||
private(set) var sourceKind: SourceKind = .off
|
||||
private(set) var isConnected = false
|
||||
|
||||
private var device: VZVirtioSocketDevice?
|
||||
private var connection: VZVirtioSocketConnection?
|
||||
private var connectionFD: Int32 = -1
|
||||
private var producer: VPhoneFrameProducer?
|
||||
private var timer: DispatchSourceTimer?
|
||||
private var connectionAttemptToken: UInt64 = 0
|
||||
|
||||
private let sendQueue = DispatchQueue(
|
||||
label: "com.vphone.camera.send", qos: .userInteractive)
|
||||
private let producerQueue = DispatchQueue(
|
||||
label: "com.vphone.camera.producer", qos: .userInteractive)
|
||||
|
||||
var onConnectionStateChange: ((Bool) -> Void)?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func connect(device: VZVirtioSocketDevice) {
|
||||
self.device = device
|
||||
attemptConnect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
stopStreaming()
|
||||
if connectionFD >= 0 {
|
||||
close(connectionFD)
|
||||
connectionFD = -1
|
||||
}
|
||||
connection = nil
|
||||
if isConnected {
|
||||
isConnected = false
|
||||
onConnectionStateChange?(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source selection
|
||||
|
||||
func setSource(_ kind: SourceKind, videoURL: URL? = nil) {
|
||||
if sourceKind == kind, kind != .videoFile { return }
|
||||
let wasStreaming = (timer != nil)
|
||||
if wasStreaming { stopStreaming() }
|
||||
sourceKind = kind
|
||||
switch kind {
|
||||
case .off:
|
||||
producer = nil
|
||||
case .testPattern:
|
||||
producer = VPhoneTestPatternProducer(
|
||||
width: Self.defaultWidth,
|
||||
height: Self.defaultHeight)
|
||||
case .videoFile:
|
||||
guard let url = videoURL else {
|
||||
print("[camera] videoFile source requires a URL")
|
||||
producer = nil
|
||||
sourceKind = .off
|
||||
return
|
||||
}
|
||||
do {
|
||||
producer = try VPhoneVideoFileProducer(
|
||||
url: url,
|
||||
width: Self.defaultWidth,
|
||||
height: Self.defaultHeight)
|
||||
print("[camera] video file source = \(url.lastPathComponent)")
|
||||
} catch {
|
||||
print("[camera] failed to open \(url.lastPathComponent): \(error)")
|
||||
producer = nil
|
||||
sourceKind = .off
|
||||
}
|
||||
}
|
||||
if wasStreaming, producer != nil {
|
||||
startStreaming()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Streaming
|
||||
|
||||
func startStreaming() {
|
||||
guard producer != nil, isConnected else { return }
|
||||
if timer != nil { return }
|
||||
let interval = 1.0 / Self.defaultFPS
|
||||
// Timer fires on the main queue so MainActor-isolated state
|
||||
// (producer, connectionFD) can be read directly without tripping
|
||||
// Swift 6's strict-concurrency isolation check. The frame
|
||||
// production + send is then hopped onto producerQueue so
|
||||
// BGRA generation doesn't run on the UI thread.
|
||||
let t = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
|
||||
t.schedule(deadline: .now(), repeating: interval, leeway: .milliseconds(2))
|
||||
t.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let producer = self.producer else { return }
|
||||
let fd = self.connectionFD
|
||||
guard fd >= 0 else { return }
|
||||
let q = self.producerQueue
|
||||
q.async {
|
||||
guard let frame = producer.nextFrame() else { return }
|
||||
let ok = Self.send(fd: fd, frame: frame)
|
||||
if !ok {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
// Avoid double-handling if a parallel write already
|
||||
// dropped the connection.
|
||||
if self.connectionFD == fd {
|
||||
self.handleDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
timer = t
|
||||
t.resume()
|
||||
print("[camera] streaming started — source=\(sourceKind.rawValue)")
|
||||
}
|
||||
|
||||
func stopStreaming() {
|
||||
guard let t = timer else { return }
|
||||
t.cancel()
|
||||
timer = nil
|
||||
print("[camera] streaming stopped")
|
||||
}
|
||||
|
||||
/// Mark the current connection dead and start reconnecting. Called when
|
||||
/// a write fails with EPIPE — the most common case is vphoned auto-update
|
||||
/// killing its server-side socket while the host is streaming.
|
||||
private func handleDisconnect() {
|
||||
print("[camera] disconnect detected, will reconnect")
|
||||
let oldFD = connectionFD
|
||||
connectionFD = -1
|
||||
connection = nil
|
||||
if isConnected {
|
||||
isConnected = false
|
||||
onConnectionStateChange?(false)
|
||||
}
|
||||
if oldFD >= 0 { close(oldFD) }
|
||||
// Note: streaming timer continues running but ticks no-op until
|
||||
// connectionFD becomes valid again.
|
||||
attemptConnect()
|
||||
}
|
||||
|
||||
// MARK: - Connect
|
||||
|
||||
private func attemptConnect() {
|
||||
guard let device else { return }
|
||||
connectionAttemptToken &+= 1
|
||||
let attemptToken = connectionAttemptToken
|
||||
device.connect(toPort: Self.vsockPort) {
|
||||
[weak self] (result: Result<VZVirtioSocketConnection, any Error>) in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
guard self.connectionAttemptToken == attemptToken else { return }
|
||||
switch result {
|
||||
case let .success(conn):
|
||||
self.connection = conn
|
||||
self.connectionFD = conn.fileDescriptor
|
||||
self.isConnected = true
|
||||
print("[camera] connected on vsock port \(Self.vsockPort)")
|
||||
self.onConnectionStateChange?(true)
|
||||
if self.sourceKind != .off { self.startStreaming() }
|
||||
case let .failure(error):
|
||||
print("[camera] connect failed: \(error). Retrying in 3s.")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
guard self.connectionAttemptToken == attemptToken else { return }
|
||||
self.attemptConnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wire
|
||||
|
||||
@discardableResult
|
||||
nonisolated private static func send(fd: Int32, frame: VPhoneCameraFrame) -> Bool {
|
||||
// header
|
||||
let headerDict: [String: Any] = [
|
||||
"w": frame.width,
|
||||
"h": frame.height,
|
||||
"bpr": frame.bytesPerRow,
|
||||
"fmt": Self.pixelFormat,
|
||||
"ts": frame.timestampNS,
|
||||
]
|
||||
guard
|
||||
let headerData = try? JSONSerialization.data(withJSONObject: headerDict, options: [])
|
||||
else { return false }
|
||||
let totalLen = UInt32(4 + headerData.count + frame.pixels.count)
|
||||
let headerLen = UInt32(headerData.count)
|
||||
var prefix = Data()
|
||||
prefix.append(contentsOf: withUnsafeBytes(of: totalLen.littleEndian) { Array($0) })
|
||||
prefix.append(contentsOf: withUnsafeBytes(of: headerLen.littleEndian) { Array($0) })
|
||||
// Concat all into one buffer to make a single write — short frames
|
||||
// are cheap; large ones (1280*720*4 ≈ 3.5 MB) still fit comfortably
|
||||
// in a vsock send buffer and a single write keeps frame integrity
|
||||
// even if a future reader uses non-buffered I/O.
|
||||
var out = Data(capacity: Int(totalLen) + 4)
|
||||
out.append(prefix)
|
||||
out.append(headerData)
|
||||
out.append(frame.pixels)
|
||||
var ok = true
|
||||
out.withUnsafeBytes { bytes -> Void in
|
||||
guard let base = bytes.baseAddress else { ok = false; return }
|
||||
var remaining = bytes.count
|
||||
var cursor = base
|
||||
while remaining > 0 {
|
||||
let n = write(fd, cursor, remaining)
|
||||
if n < 0 {
|
||||
if errno == EINTR { continue }
|
||||
print("[camera] write errno=\(errno)")
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
if n == 0 { ok = false; return }
|
||||
remaining -= n
|
||||
cursor = cursor.advanced(by: n)
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
import CoreImage
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
|
||||
/// A single BGRA frame produced by a frame source.
|
||||
struct VPhoneCameraFrame: Sendable {
|
||||
let width: Int
|
||||
let height: Int
|
||||
let bytesPerRow: Int
|
||||
let timestampNS: UInt64
|
||||
let pixels: Data
|
||||
}
|
||||
|
||||
/// Frame source for the host-side virtual camera server.
|
||||
///
|
||||
/// `@unchecked Sendable` because each conforming producer is mutated only
|
||||
/// from the camera server's producer queue (single writer); Swift can't
|
||||
/// see the queue isolation, so we opt out of the strict-concurrency
|
||||
/// check.
|
||||
protocol VPhoneFrameProducer: AnyObject, Sendable {
|
||||
func nextFrame() -> VPhoneCameraFrame?
|
||||
}
|
||||
|
||||
// MARK: - Test pattern
|
||||
|
||||
/// Generates a smoothly-animating BGRA pattern: a moving vertical gradient
|
||||
/// modulated by a sin wave, plus a frame counter overlay in the corner so
|
||||
/// the receiver can verify that frames are advancing. No external assets
|
||||
/// required.
|
||||
final class VPhoneTestPatternProducer: VPhoneFrameProducer, @unchecked Sendable {
|
||||
private let width: Int
|
||||
private let height: Int
|
||||
private let bytesPerRow: Int
|
||||
private var frameIndex: UInt64 = 0
|
||||
private let startedAt: TimeInterval
|
||||
|
||||
init(width: Int, height: Int) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
// Round bytesPerRow up to 16-byte alignment — common requirement for
|
||||
// IOSurfaces and BGRA hardware paths. For 1280 width: 1280*4 = 5120
|
||||
// (already 16-aligned).
|
||||
let stride = ((width * 4) + 15) & ~15
|
||||
self.bytesPerRow = stride
|
||||
self.startedAt = ProcessInfo.processInfo.systemUptime
|
||||
}
|
||||
|
||||
func nextFrame() -> VPhoneCameraFrame? {
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
let elapsed = now - startedAt
|
||||
let pixelCount = bytesPerRow * height
|
||||
var bytes = [UInt8](repeating: 0, count: pixelCount)
|
||||
|
||||
// Vertical gradient. Hue rolls with time.
|
||||
let hueOffset = elapsed * 0.4 // turns per second
|
||||
for y in 0..<height {
|
||||
let v = Double(y) / Double(height)
|
||||
// Simple HSV → RGB on hue, full sat/val.
|
||||
let h = (v + hueOffset).truncatingRemainder(dividingBy: 1.0)
|
||||
let (r, g, b) = Self.hsvToRGB(h: h, s: 0.85, v: 0.85)
|
||||
let rB = UInt8(min(255, max(0, Int(r * 255))))
|
||||
let gB = UInt8(min(255, max(0, Int(g * 255))))
|
||||
let bB = UInt8(min(255, max(0, Int(b * 255))))
|
||||
|
||||
let rowStart = y * bytesPerRow
|
||||
var idx = rowStart
|
||||
// BGRA order on little-endian Apple platforms.
|
||||
for _ in 0..<width {
|
||||
bytes[idx + 0] = bB // B
|
||||
bytes[idx + 1] = gB // G
|
||||
bytes[idx + 2] = rB // R
|
||||
bytes[idx + 3] = 255 // A
|
||||
idx += 4
|
||||
}
|
||||
}
|
||||
|
||||
// Counter overlay: a moving small white square (poor man's frame
|
||||
// counter — the receiver can eyeball motion to confirm fps).
|
||||
let sqSize = 32
|
||||
let xPos = Int(elapsed * 200) % max(1, width - sqSize)
|
||||
let yPos = max(8, (height - sqSize) / 8)
|
||||
for dy in 0..<sqSize {
|
||||
let row = (yPos + dy) * bytesPerRow
|
||||
for dx in 0..<sqSize {
|
||||
let off = row + (xPos + dx) * 4
|
||||
bytes[off + 0] = 255
|
||||
bytes[off + 1] = 255
|
||||
bytes[off + 2] = 255
|
||||
bytes[off + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
frameIndex &+= 1
|
||||
let ts = UInt64(now * 1_000_000_000)
|
||||
return VPhoneCameraFrame(
|
||||
width: width,
|
||||
height: height,
|
||||
bytesPerRow: bytesPerRow,
|
||||
timestampNS: ts,
|
||||
pixels: Data(bytes))
|
||||
}
|
||||
|
||||
// MARK: - HSV helpers
|
||||
|
||||
private static func hsvToRGB(h: Double, s: Double, v: Double) -> (Double, Double, Double) {
|
||||
let i = floor(h * 6.0)
|
||||
let f = h * 6.0 - i
|
||||
let p = v * (1.0 - s)
|
||||
let q = v * (1.0 - f * s)
|
||||
let t = v * (1.0 - (1.0 - f) * s)
|
||||
switch Int(i) % 6 {
|
||||
case 0: return (v, t, p)
|
||||
case 1: return (q, v, p)
|
||||
case 2: return (p, v, t)
|
||||
case 3: return (p, q, v)
|
||||
case 4: return (t, p, v)
|
||||
default: return (v, p, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video file (.mov / .mp4 / .m4v via AVAssetReader)
|
||||
|
||||
/// Plays a video file in a loop. Decode is delegated to `AVAssetReader`
|
||||
/// with a BGRA output spec, so anything AVFoundation can demux on macOS
|
||||
/// works (`.mov`, `.mp4`, `.m4v`). For unsupported containers
|
||||
/// (`.mkv`, `.webm`, `.avi`) convert externally first
|
||||
/// (e.g. `ffmpeg -i in.mkv -c copy out.mov` if codecs are compatible).
|
||||
///
|
||||
/// The producer rescales the input video to the configured camera
|
||||
/// width/height using a Core Image render so the wire-format payload
|
||||
/// length stays constant regardless of the source resolution. Output is
|
||||
/// always 8-bit BGRA, top-down, 16-byte aligned bytesPerRow.
|
||||
final class VPhoneVideoFileProducer: VPhoneFrameProducer, @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let width: Int
|
||||
private let height: Int
|
||||
private let bytesPerRow: Int
|
||||
private var asset: AVURLAsset
|
||||
private var reader: AVAssetReader?
|
||||
private var readerOutput: AVAssetReaderTrackOutput?
|
||||
private let ciContext: CIContext
|
||||
|
||||
init(url: URL, width: Int, height: Int) throws {
|
||||
self.url = url
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.bytesPerRow = ((width * 4) + 15) & ~15
|
||||
self.asset = AVURLAsset(url: url)
|
||||
self.ciContext = CIContext(options: [.useSoftwareRenderer: false])
|
||||
try restartReader()
|
||||
}
|
||||
|
||||
private func restartReader() throws {
|
||||
guard let track = asset.tracks(withMediaType: .video).first else {
|
||||
throw NSError(
|
||||
domain: "VPhoneVideoFileProducer", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey:
|
||||
"\(url.lastPathComponent): no video track"])
|
||||
}
|
||||
let r = try AVAssetReader(asset: asset)
|
||||
let output = AVAssetReaderTrackOutput(
|
||||
track: track,
|
||||
outputSettings: [
|
||||
kCVPixelBufferPixelFormatTypeKey as String:
|
||||
Int(kCVPixelFormatType_32BGRA),
|
||||
])
|
||||
output.alwaysCopiesSampleData = false
|
||||
r.add(output)
|
||||
guard r.startReading() else {
|
||||
throw NSError(
|
||||
domain: "VPhoneVideoFileProducer", code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey:
|
||||
"AVAssetReader.startReading failed: \(r.error?.localizedDescription ?? "?")"])
|
||||
}
|
||||
self.reader = r
|
||||
self.readerOutput = output
|
||||
}
|
||||
|
||||
func nextFrame() -> VPhoneCameraFrame? {
|
||||
if reader?.status != .reading {
|
||||
// Loop on EOF (or after an error)
|
||||
do { try restartReader() } catch { print("[camera] mov restart failed: \(error)"); return nil }
|
||||
}
|
||||
guard let sb = readerOutput?.copyNextSampleBuffer(),
|
||||
let pb = CMSampleBufferGetImageBuffer(sb)
|
||||
else {
|
||||
// EOF — restart on next call.
|
||||
do { try restartReader() } catch {}
|
||||
return nil
|
||||
}
|
||||
|
||||
let srcWidth = CVPixelBufferGetWidth(pb)
|
||||
let srcHeight = CVPixelBufferGetHeight(pb)
|
||||
|
||||
// Fast path: source already matches our requested dimensions and is
|
||||
// BGRA — copy the planar bytes directly without going through Core
|
||||
// Image. Saves the GPU render.
|
||||
if srcWidth == width, srcHeight == height,
|
||||
CVPixelBufferGetPixelFormatType(pb) == kCVPixelFormatType_32BGRA {
|
||||
CVPixelBufferLockBaseAddress(pb, .readOnly)
|
||||
defer { CVPixelBufferUnlockBaseAddress(pb, .readOnly) }
|
||||
let srcBPR = CVPixelBufferGetBytesPerRow(pb)
|
||||
guard let base = CVPixelBufferGetBaseAddress(pb) else { return nil }
|
||||
var out = Data(count: bytesPerRow * height)
|
||||
out.withUnsafeMutableBytes { dst in
|
||||
let dstBase = dst.baseAddress!
|
||||
for y in 0..<height {
|
||||
let dstRow = dstBase.advanced(by: y * bytesPerRow)
|
||||
let srcRow = base.advanced(by: y * srcBPR)
|
||||
let copyLen = min(srcBPR, bytesPerRow)
|
||||
memcpy(dstRow, srcRow, copyLen)
|
||||
}
|
||||
}
|
||||
return VPhoneCameraFrame(
|
||||
width: width, height: height,
|
||||
bytesPerRow: bytesPerRow,
|
||||
timestampNS: UInt64(ProcessInfo.processInfo.systemUptime * 1e9),
|
||||
pixels: out)
|
||||
}
|
||||
|
||||
// Slow path: resize via Core Image. Stretches to fit; pick aspect
|
||||
// strategy here if you want letterboxing instead.
|
||||
let srcImage = CIImage(cvPixelBuffer: pb)
|
||||
let scaleX = CGFloat(width) / CGFloat(srcWidth)
|
||||
let scaleY = CGFloat(height) / CGFloat(srcHeight)
|
||||
let scaled = srcImage.transformed(
|
||||
by: CGAffineTransform(scaleX: scaleX, y: scaleY))
|
||||
|
||||
var out = Data(count: bytesPerRow * height)
|
||||
out.withUnsafeMutableBytes { dst in
|
||||
let dstBase = dst.baseAddress!
|
||||
ciContext.render(
|
||||
scaled,
|
||||
toBitmap: dstBase,
|
||||
rowBytes: bytesPerRow,
|
||||
bounds: CGRect(x: 0, y: 0, width: width, height: height),
|
||||
format: .BGRA8,
|
||||
colorSpace: CGColorSpaceCreateDeviceRGB())
|
||||
}
|
||||
return VPhoneCameraFrame(
|
||||
width: width, height: height,
|
||||
bytesPerRow: bytesPerRow,
|
||||
timestampNS: UInt64(ProcessInfo.processInfo.systemUptime * 1e9),
|
||||
pixels: out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - Camera Menu
|
||||
|
||||
extension VPhoneMenuController {
|
||||
func buildCameraSubmenu() -> NSMenuItem {
|
||||
let item = NSMenuItem(title: "Camera", action: nil, keyEquivalent: "")
|
||||
let menu = NSMenu(title: "Camera")
|
||||
|
||||
let status = NSMenuItem(title: "Camera server: disconnected",
|
||||
action: nil, keyEquivalent: "")
|
||||
status.isEnabled = false
|
||||
cameraStatusItem = status
|
||||
menu.addItem(status)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let off = makeItem("Source: Off", action: #selector(setCameraSourceOff))
|
||||
off.state = .on
|
||||
cameraSourceOffItem = off
|
||||
menu.addItem(off)
|
||||
|
||||
let testPattern = makeItem("Source: Test Pattern",
|
||||
action: #selector(setCameraSourceTestPattern))
|
||||
cameraSourceTestPatternItem = testPattern
|
||||
menu.addItem(testPattern)
|
||||
|
||||
let videoFile = makeItem("Source: Video File…",
|
||||
action: #selector(setCameraSourceVideoFile))
|
||||
cameraSourceVideoFileItem = videoFile
|
||||
menu.addItem(videoFile)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let startStop = makeItem("Start Streaming",
|
||||
action: #selector(toggleCameraStreaming))
|
||||
startStop.isEnabled = false
|
||||
cameraStartStopItem = startStop
|
||||
menu.addItem(startStop)
|
||||
|
||||
item.submenu = menu
|
||||
return item
|
||||
}
|
||||
|
||||
func updateCameraConnectionState(connected: Bool) {
|
||||
cameraStatusItem?.title = connected
|
||||
? "Camera server: connected (vsock 1338)"
|
||||
: "Camera server: disconnected"
|
||||
cameraStartStopItem?.isEnabled = connected &&
|
||||
(cameraServer?.sourceKind ?? .off) != .off
|
||||
}
|
||||
|
||||
private func refreshCameraSourceCheckmarks() {
|
||||
let kind = cameraServer?.sourceKind ?? .off
|
||||
cameraSourceOffItem?.state = (kind == .off) ? .on : .off
|
||||
cameraSourceTestPatternItem?.state =
|
||||
(kind == .testPattern) ? .on : .off
|
||||
cameraSourceVideoFileItem?.state =
|
||||
(kind == .videoFile) ? .on : .off
|
||||
}
|
||||
|
||||
@objc func setCameraSourceOff() {
|
||||
cameraServer?.stopStreaming()
|
||||
cameraServer?.setSource(.off)
|
||||
refreshCameraSourceCheckmarks()
|
||||
cameraStartStopItem?.isEnabled = false
|
||||
cameraStartStopItem?.title = "Start Streaming"
|
||||
}
|
||||
|
||||
@objc func setCameraSourceTestPattern() {
|
||||
cameraServer?.setSource(.testPattern)
|
||||
refreshCameraSourceCheckmarks()
|
||||
cameraStartStopItem?.isEnabled = (cameraServer?.isConnected ?? false)
|
||||
}
|
||||
|
||||
@objc func setCameraSourceVideoFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
// AVFoundation's natively-decodable containers on macOS. Anything
|
||||
// beyond these (.mkv/.webm/.avi) would need external conversion to
|
||||
// .mov / .mp4 first.
|
||||
panel.allowedContentTypes = [
|
||||
UTType(filenameExtension: "mov") ?? .movie,
|
||||
UTType(filenameExtension: "mp4") ?? .movie,
|
||||
UTType(filenameExtension: "m4v") ?? .movie,
|
||||
]
|
||||
panel.prompt = "Use as Camera Source"
|
||||
panel.title = "Pick a video file"
|
||||
panel.runModal()
|
||||
guard let url = panel.url else { return }
|
||||
cameraServer?.setSource(.videoFile, videoURL: url)
|
||||
refreshCameraSourceCheckmarks()
|
||||
cameraStartStopItem?.isEnabled =
|
||||
(cameraServer?.isConnected ?? false) &&
|
||||
(cameraServer?.sourceKind ?? .off) == .videoFile
|
||||
}
|
||||
|
||||
@objc func toggleCameraStreaming() {
|
||||
guard let server = cameraServer else { return }
|
||||
if cameraStartStopItem?.title == "Start Streaming" {
|
||||
server.startStreaming()
|
||||
cameraStartStopItem?.title = "Stop Streaming"
|
||||
} else {
|
||||
server.stopStreaming()
|
||||
cameraStartStopItem?.title = "Start Streaming"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ extension VPhoneMenuController {
|
||||
|
||||
menu.addItem(buildLocationSubmenu())
|
||||
menu.addItem(buildBatterySubmenu())
|
||||
menu.addItem(buildCameraSubmenu())
|
||||
|
||||
item.submenu = menu
|
||||
return item
|
||||
|
||||
@@ -35,6 +35,12 @@ class VPhoneMenuController {
|
||||
var locationReplayStopItem: NSMenuItem?
|
||||
var screenRecorder: VPhoneScreenRecorder?
|
||||
var recordingItem: NSMenuItem?
|
||||
var cameraServer: VPhoneCameraServer?
|
||||
var cameraStatusItem: NSMenuItem?
|
||||
var cameraSourceOffItem: NSMenuItem?
|
||||
var cameraSourceTestPatternItem: NSMenuItem?
|
||||
var cameraSourceVideoFileItem: NSMenuItem?
|
||||
var cameraStartStopItem: NSMenuItem?
|
||||
weak var captureView: VPhoneVirtualMachineView?
|
||||
var batterySyncEnabled = false
|
||||
var batterySyncStatusItem: NSMenuItem?
|
||||
|
||||
Reference in New Issue
Block a user