From acf8e83cbf106b4350536d54eb46379dd86a623c Mon Sep 17 00:00:00 2001 From: Runt <qingingrunt2010@qq.com> Date: Fri, 04 Jul 2025 17:05:00 +0000 Subject: [PATCH] 输入弹框 图标修改 相机调整 --- LiveProject/views/VideoRendererView.swift | 38 + .gitignore | 1 LiveProject/enum/Icons.swift | 24 + LiveProject/tool/H264Encoder.swift | 166 ++++++++ LiveProject/activity/stream/LiveActivity.swift | 102 +++- LiveProject/tool/MetalRenderer.swift | 127 +++-- LiveProject/data/DeviceInfo.swift | 2 /dev/null | 12 LiveProject/views/FlowLayout.swift | 190 ++------ LiveProject/controller/CameraCapture.swift | 31 + LiveProject/tool/PixelBufferConverter.swift | 44 ++ LiveProject/activity/stream/LiveViewModel.swift | 101 ++++ LiveProject/tool/MetalPixelConverter.swift | 227 +++++++++++ LiveProject/data/IconInfo.swift | 12 LiveProject/views/MButton.swift | 16 LiveProject/views/LTextField.swift | 73 +++ LiveProject/views/TitleBarView.swift | 8 17 files changed, 925 insertions(+), 249 deletions(-) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85e7c1d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea/ diff --git a/LiveProject/activity/stream/LiveActivity.swift b/LiveProject/activity/stream/LiveActivity.swift index ef284a2..57e7890 100644 --- a/LiveProject/activity/stream/LiveActivity.swift +++ b/LiveProject/activity/stream/LiveActivity.swift @@ -11,10 +11,9 @@ import MetalKit struct LiveActivity: View { - @State private var pixelBuffer: CVPixelBuffer? - @State private var showDeviceDialog = false + @State private var showInputDialog = false @State private var streamRate = Float(9/16.0); @State private var fpsState = 30; @@ -22,27 +21,27 @@ @State private var displaySize : CGSize = .zero; - @State private var devices = [DeviceInfo(name: "相机", type: .CAMERA, deviceId: UUID().uuidString,icon: IconCamera()), - DeviceInfo(name: "话筒", type: .MICROPHONE,deviceId: UUID().uuidString,icon: IconMic()), - DeviceInfo(name: "系统", type: .SYSTEM,deviceId : UUID().uuidString,icon: IconPortrait())] + @State private var devices = [DeviceInfo(name: "相机", type: .CAMERA, deviceId: UUID().uuidString,icon: Icons.CAMERA), + DeviceInfo(name: "话筒", type: .MICROPHONE,deviceId: UUID().uuidString,icon: Icons.MIC), + DeviceInfo(name: "系统", type: .SYSTEM,deviceId : UUID().uuidString,icon: Icons.PORTRAIT)] - private let mViewModel = LiveViewModel() + @StateObject private var mViewModel = LiveViewModel() var body: some View { ZStack{ Color.clear .ignoresSafeArea() // 填满全屏 VStack{ - VideoRendererView(renderer:mViewModel.renderer).background(Color.black).frame(width: mainSize.width,height:mainSize.height) + VideoRendererView(pixelBuffer: $mViewModel.pixelBuffer).background(Color.black).frame(width: mainSize.width,height:mainSize.height) Spacer() }.border(Color.blue) VStack{ Spacer() BottomBtns().frame(alignment: .bottom).border(Color.green) } - if showDeviceDialog { - DialogDevices() + if showInputDialog{ + DialogInput() } }.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) .background( @@ -99,20 +98,59 @@ print("updateWindow:\(mainSize)") } - func DialogDevices() -> some View{ + func DialogInput(onCancel:() ->Void = {},onConfirm:() -> Void = {}) -> some View{ ZStack{ Color.black.opacity(0.4) .edgesIgnoringSafeArea(.all) .onTapGesture { withAnimation { - showDeviceDialog = false + showInputDialog = false } } VStack { - Spacer() - VStack(spacing: 20) { - Spacer().frame(height:40) - FlowLayout(devices){ device in + VStack(alignment: .leading, spacing: 40) { + Text("请输入直播地址") + .font(Font.system(size: 20)) + LTextField().environmentObject(LText()) + HStack{ + Spacer() + Button(action:{ + showInputDialog.toggle(); + }){ + Text("取消") + .font(Font.system(size: 20)) + .foregroundColor(Color.gray) + } + + Spacer().frame(width: 30) + Button(action:{ + showInputDialog.toggle(); + }){ + Text("确认") + .font(Font.system(size: 20)) + .foregroundColor(Color.colorTextLink) + } + + } + } + .padding(30) + .background(Color.white) + .cornerRadius(20) + .transition(.move(edge: .bottom)) + } + .padding(60) + .zIndex(1) + .animation(.default, value: devices) + } + } + + func DialogDevices() -> some View{ + VStack{ + VStack(spacing: 20) { + Spacer().frame(height:20) + FlowLayout(){ + + ForEach(devices, id: \.self) { device in MButton(icon: device.icon,text: device.name){ mViewModel.newWindowAction(device: device){ status in withAnimation{ @@ -122,17 +160,12 @@ print("\(device.name) click") } } - .padding() } - .frame(maxWidth: .infinity) - .padding() - .background(Color.white) - .cornerRadius(20) - .transition(.move(edge: .bottom)) } - .zIndex(1) - .animation(.default, value: devices) + .frame(maxWidth: .infinity,alignment:.leading) + .padding() } + .frame(maxHeight: .infinity,alignment:.topLeading) } func BottomBtns() -> some View{ @@ -140,7 +173,7 @@ HStack(){ //横竖屏控制 - MButton(icon:streamRate == (9/16.0) ? IconPortrait() : IconLandscape() ){ + MButton(icon:streamRate == (9/16.0) ? Icons.PORTRAIT : Icons.LANDSCAPE ){ streamRate = streamRate == (9/16.0) ? (16/9.0) : (9/16.0) updateWindowSize() } @@ -156,18 +189,29 @@ HStack{ LButton(text: "设备"){ print("Click 设备 button") - withAnimation{ - showDeviceDialog.toggle() + showDeviceDialog.toggle() + }.sheet(isPresented:$showDeviceDialog, content: { + VStack { + ScrollView { + DialogDevices() + } } - } + .presentationDetents([.height(200),.medium]) + }) LButton(text: "RTMP"){ - + print("Click RTMP button") + withAnimation{ + showInputDialog.toggle() + } } /*flLButton(text: "文件"){ }*/ LButton(text: "文本"){ - + print("Click 文本 button") + withAnimation{ + showInputDialog.toggle() + } } } HStack{ diff --git a/LiveProject/activity/stream/LiveViewModel.swift b/LiveProject/activity/stream/LiveViewModel.swift index 347d761..ab408ed 100644 --- a/LiveProject/activity/stream/LiveViewModel.swift +++ b/LiveProject/activity/stream/LiveViewModel.swift @@ -7,22 +7,54 @@ import UIKit import AVFoundation -class LiveViewModel{ +class LiveViewModel: ObservableObject { + @Published var pixelBuffer: CVPixelBuffer? + let encoder = H264Encoder(width: 1080, height: 1920, fps: 30, bitrate: 1_000_000) + var frameIndex: Int64 = 0 + let encodeQueue = DispatchQueue(label: "encoder.queue") + lazy var camera = CameraCapture() - lazy var renderer = MetalRenderer() + var timestamp = Int(Date().timeIntervalSince1970 * 1000) func newWindowAction(device:DeviceInfo,completion: @escaping (Bool) -> Void = {b in}){ switch device.type{ case StreamType.CAMERA: requestCameraPermission(mediaType: .video){ staus in if(staus){ - self.camera.onFrame = { buffer in - self.renderer.updateFrame(pixelBuffer: buffer) - print("画面更新") + var ts1 = Int(Date().timeIntervalSince1970 * 1000) + self.camera.onFrame = { [weak self] buffer in + guard let self = self else { return } + + let width = CVPixelBufferGetWidth(buffer) + let height = CVPixelBufferGetHeight(buffer) + + guard width > 0 && height > 0 else { + print("Invalid pixel buffer size: \(width)x\(height)") + return + } + + self.frameIndex += 1 + let ts = Int(Date().timeIntervalSince1970 * 1000) + + self.timestamp = ts; + let cmTime = CMTimeMake(value: Int64(CACurrentMediaTime() * 1000), timescale: 1000); + self.encoder.encode(pixelBuffer: buffer, pts: cmTime) + DispatchQueue.main.async { + self.pixelBuffer = buffer; + } + //print("画面更新") } - self.camera.start() + DispatchQueue.global(qos: .userInitiated).async { + self.camera.start() + } print("启动相机") + self.encoder.onEncoded = { (data: Data, ctime: CMTime, isKey: Bool) in + let timestamp2 = Int(Date().timeIntervalSince1970 * 1000) + print("编码时间2 \(timestamp2 - self.timestamp)") + print("Encoded NALU size: \(data.count), key frame: \(isKey)") + + } }else{ } @@ -33,6 +65,17 @@ break; } } + + func closeWindowAction(device:DeviceInfo){ + switch device.type{ + case StreamType.CAMERA: + print("关闭相机") + break; + default: + break; + } + } + func requestCameraPermission(mediaType: AVMediaType,completion: @escaping (Bool) -> Void) { @@ -51,4 +94,50 @@ completion(false) } } + + func copyPixelBuffer(_ src: CVPixelBuffer) -> CVPixelBuffer? { + let width = CVPixelBufferGetWidth(src) + let height = CVPixelBufferGetHeight(src) + let pixelFormat = CVPixelBufferGetPixelFormatType(src) + + var dst: CVPixelBuffer? + let attrs: [String: Any] = [ + kCVPixelBufferIOSurfacePropertiesKey as String: [:] + ] + + let status = CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + pixelFormat, + attrs as CFDictionary, + &dst + ) + + guard status == kCVReturnSuccess, let dstBuffer = dst else { + print("❌ 复制 PixelBuffer 失败") + return nil + } + + CVPixelBufferLockBaseAddress(src, .readOnly) + CVPixelBufferLockBaseAddress(dstBuffer, []) + + let planeCount = CVPixelBufferGetPlaneCount(src) + for i in 0..<planeCount { + let srcAddr = CVPixelBufferGetBaseAddressOfPlane(src, i) + let dstAddr = CVPixelBufferGetBaseAddressOfPlane(dstBuffer, i) + + let height = CVPixelBufferGetHeightOfPlane(src, i) + let bytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(src, i) + + memcpy(dstAddr, srcAddr, height * bytesPerRow) + } + + CVPixelBufferUnlockBaseAddress(src, .readOnly) + CVPixelBufferUnlockBaseAddress(dstBuffer, []) + + return dstBuffer + } + + } diff --git a/LiveProject/controller/CameraCapture.swift b/LiveProject/controller/CameraCapture.swift index 455b88a..3345602 100644 --- a/LiveProject/controller/CameraCapture.swift +++ b/LiveProject/controller/CameraCapture.swift @@ -8,6 +8,8 @@ class CameraCapture: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { private let session = AVCaptureSession() + private var videoOutput: AVCaptureVideoDataOutput? + private var input: AVCaptureDeviceInput? var onFrame: ((CVPixelBuffer) -> Void)? func start() { @@ -17,8 +19,10 @@ return } + self.input = input + session.beginConfiguration() - session.sessionPreset = .high + session.sessionPreset = .hd1920x1080 if session.canAddInput(input) { session.addInput(input) @@ -34,14 +38,39 @@ session.addOutput(output) } + self.videoOutput = output + session.commitConfiguration() session.startRunning() + print("📷 相机已开启") } func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard let buffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + let width = CVPixelBufferGetWidth(buffer) + let height = CVPixelBufferGetHeight(buffer) + //print("📷 当前帧尺寸: \(width)x\(height)") onFrame?(buffer) } + + func stop(){ + session.stopRunning() + session.beginConfiguration() + + if let input = input { + session.removeInput(input) + } + + if let output = videoOutput { + session.removeOutput(output) + } + + session.commitConfiguration() + + input = nil + videoOutput = nil + print("📷 相机已关闭") + } } diff --git a/LiveProject/data/DeviceInfo.swift b/LiveProject/data/DeviceInfo.swift index 86d4ecf..ff6bf3f 100644 --- a/LiveProject/data/DeviceInfo.swift +++ b/LiveProject/data/DeviceInfo.swift @@ -10,7 +10,7 @@ let name:String let type:StreamType let deviceId:String; - var icon : (any View)? = nil; + var icon : IconInfo? = nil; func hash(into hasher: inout Hasher){ hasher.combine(deviceId) diff --git a/LiveProject/data/IconInfo.swift b/LiveProject/data/IconInfo.swift new file mode 100644 index 0000000..9a07bbd --- /dev/null +++ b/LiveProject/data/IconInfo.swift @@ -0,0 +1,12 @@ +// +// IconInfo.swift +// LiveProject +// +// Created by 倪路朋 on 7/4/25. +// +import SwiftUI + +struct IconInfo{ + var name:String + var size:CGSize = CGSize(width: 20, height: 20) +} diff --git a/LiveProject/enum/Icons.swift b/LiveProject/enum/Icons.swift new file mode 100644 index 0000000..c1b1d7b --- /dev/null +++ b/LiveProject/enum/Icons.swift @@ -0,0 +1,24 @@ +// +// Icons.swift +// LiveProject +// +// Created by 倪路朋 on 7/4/25. +// +import SwiftUI + +struct Icons{ + static let CAMERA = IconInfo(name: "camera",size: CGSize(width: 25, height: 20)) + static let MIC = IconInfo(name: "mic",size: CGSize(width: 15, height: 23)) + static let MIC_MUTE = IconInfo(name: "mic.slash",size: CGSize(width: 20, height: 23)) + static let PORTRAIT = IconInfo(name: "ipad",size: CGSize(width: 18, height: 23)) + static let LANDSCAPE = IconInfo(name: "ipad.landscape",size: CGSize(width: 25, height: 20)) + static let BACK = IconInfo(name: "arrow.left",size: CGSize(width: 25, height: 20)) + static let SPEAKER = IconInfo(name: "speaker",size: CGSize(width: 18, height: 23)) + static let SPEAKER_MUTE = IconInfo(name: "speaker.slash",size: CGSize(width: 18, height: 23)) + static let IMAGE = IconInfo(name: "photo",size: CGSize(width: 25, height: 23)) + static let IMAGE_MUTE = IconInfo(name: "photo.slash",size: CGSize(width: 25, height: 23)) + static let ROTATE_LEFT = IconInfo(name: "rotate.left",size: CGSize(width: 25, height: 25)) + static let ROTATE_RIGHT = IconInfo(name: "rotate.right",size: CGSize(width: 25, height: 25)) + static let INFO = IconInfo(name: "info.circle",size: CGSize(width: 25, height: 25)) + static let PAINT = IconInfo(name: "paintpalette",size: CGSize(width: 25, height: 25)) +} diff --git a/LiveProject/shape/IconBack.swift b/LiveProject/shape/IconBack.swift deleted file mode 100644 index 5d760f8..0000000 --- a/LiveProject/shape/IconBack.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// IconBack.swift -// LiveProject -// -// Created by 倪路朋 on 6/26/25. -// - -import SwiftUI - -struct IconBack: Shape { - - func path(in rect: CGRect) -> Path { - var path = Path() - - let arrowHeadLength = rect.width * 0.4 - let shaftY = rect.midY - let headHeight = rect.height * 0.5 - - // 箭头头部(三角形) - path.move(to: CGPoint(x: arrowHeadLength, y: rect.minY)) - path.addLine(to: CGPoint(x: 0, y: shaftY)) - path.addLine(to: CGPoint(x: arrowHeadLength, y: rect.maxY)) - - // 箭身(横线) - path.move(to: CGPoint(x: 0, y: shaftY)) - path.addLine(to: CGPoint(x: rect.maxX, y: shaftY)) - return path - } -} - -struct IconBack_Previews : PreviewProvider{ - static var previews: some View { - IconBack() - .stroke(Color.primary, lineWidth: 3).frame(width: 30,height: 25) - } -} diff --git a/LiveProject/shape/IconCamera.swift b/LiveProject/shape/IconCamera.swift deleted file mode 100644 index 2bd8d01..0000000 --- a/LiveProject/shape/IconCamera.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// IconCamera.swift -// LiveProject -// -// Created by 倪路朋 on 6/27/25. -// - -import SwiftUI - -struct IconCameraShape: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - - // 比例参数 - let cornerRadius = rect.height * 0.1 - let bodyInset = rect.height * 0.1 - let lensDiameter = rect.height * 0.3 - let flashSize = CGSize(width: rect.width * 0.08, height: rect.height * 0.08) - - // 相机机身(主圆角矩形) - let bodyRect = rect.insetBy(dx: 0, dy: bodyInset) - path.addRoundedRect(in: bodyRect, cornerSize: CGSize(width: cornerRadius, height: cornerRadius)) - - // 镜头(中间大圆) - let lensOrigin = CGPoint( - x: rect.midX - lensDiameter / 2, - y: rect.midY - lensDiameter / 2 - ) - let lensRect = CGRect(origin: lensOrigin, size: CGSize(width: lensDiameter, height: lensDiameter)) - path.addEllipse(in: lensRect) - - // 闪光灯(机身内的小圆点) - let flashOrigin = CGPoint( - x: bodyRect.maxX - flashSize.width * 1.5, - y: bodyRect.minY + flashSize.height * 1.5 - ) - let flashRect = CGRect(origin: flashOrigin, size: flashSize) - path.addEllipse(in: flashRect) - - return path - } -} - -struct IconCamera: View { - var color: Color = .white - var size: CGSize = CGSize(width: 20, height:20) - var lineWidth: CGFloat = 2.0 - - var body: some View { - IconCameraShape() - .stroke(color, lineWidth: lineWidth) - .frame(width: size.width, height: size.height) - } -} - -struct IconCamera_Previews : PreviewProvider{ - static var previews: some View { - IconCamera(color: .black).background(.red) - } -} diff --git a/LiveProject/shape/IconLandscape.swift b/LiveProject/shape/IconLandscape.swift deleted file mode 100644 index 793c0d8..0000000 --- a/LiveProject/shape/IconLandscape.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// IconLand.swift -// LiveProject -// -// Created by 倪路朋 on 6/25/25. -// - - -import SwiftUI - -struct IconLandscape: View { - var color: Color = .white - var size: CGSize = CGSize(width: 20, height:15) - var lineWidth: CGFloat = 2.0 - - var body: some View { - IconRect() - .stroke(color, lineWidth: lineWidth) - .frame(width: size.width, height: size.height) - } -} - -struct IconLandscape_Previews : PreviewProvider{ - static var previews: some View { - IconLandscape(color: .black,size: CGSize(width: 90, height: 60)) - } -} diff --git a/LiveProject/shape/IconMic.swift b/LiveProject/shape/IconMic.swift deleted file mode 100644 index 91aec35..0000000 --- a/LiveProject/shape/IconMic.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// IconMic.swift -// LiveProject -// -// Created by 倪路朋 on 6/27/25. -// -import SwiftUI - -struct IconMicShape: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - - // 计算容器高度(麦克风主体 + 支架 + 底座 + 一点留白) - let micHeight = rect.height * 0.5 - let stemHeight = rect.height * 0.1 - let baseHeight = rect.height * 0.02 - let containerHeight = micHeight + stemHeight + baseHeight - - // 计算顶部偏移量,确保垂直居中 - let topOffset = (rect.height - containerHeight) / 2 - - // 主体:麦克风(居中) - let micWidth = rect.width * 0.3 - let micRect = CGRect( - x: rect.midX - micWidth / 2, - y: topOffset, - width: micWidth, - height: micHeight - ) - path.addRoundedRect(in: micRect, cornerSize: CGSize(width: micWidth / 2, height: micWidth / 2)) - - // 支架 - let stemTop = CGPoint(x: rect.midX, y: micRect.maxY) - let stemBottom = CGPoint(x: rect.midX, y: stemTop.y + stemHeight) - path.move(to: stemTop) - path.addLine(to: stemBottom) - - // 底座 - let baseWidth = rect.width * 0.3 - path.move(to: CGPoint(x: rect.midX - baseWidth / 2, y: stemBottom.y)) - path.addLine(to: CGPoint(x: rect.midX + baseWidth / 2, y: stemBottom.y)) - - return path - } -} -struct IconMic: View { - var color: Color = .white - var size: CGSize = CGSize(width: 20, height:30) - var lineWidth: CGFloat = 2.0 - - var body: some View { - IconMicShape() - .stroke(color, lineWidth: lineWidth) - .frame(width: size.width, height: size.height) - } -} - - -struct IconMic_Previews : PreviewProvider{ - static var previews: some View { - IconMic(color: .black).background(.red) - } -} diff --git a/LiveProject/shape/IconPortrait.swift b/LiveProject/shape/IconPortrait.swift deleted file mode 100644 index 8aa380b..0000000 --- a/LiveProject/shape/IconPortrait.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// IconPortrait.swift -// LiveProject -// -// Created by 倪路朋 on 6/25/25. -// - -import SwiftUI - -struct IconPortrait: View { - var color: Color = .white - var size: CGSize = CGSize(width: 15, height:20) - var lineWidth: CGFloat = 2.0 - var padding = EdgeInsets(top: 0, leading: 2.5, bottom: 0, trailing: 2.5) - - var body: some View { - IconRect() - .stroke(color, lineWidth: lineWidth) - .frame(width: size.width, height: size.height) - .padding(padding) - } -} - -struct IconPortrait_Previews : PreviewProvider{ - static var previews: some View { - IconPortrait(color: .black,size: CGSize(width: 60, height: 90)) - } -} diff --git a/LiveProject/shape/IconRect.swift b/LiveProject/shape/IconRect.swift deleted file mode 100644 index c54307f..0000000 --- a/LiveProject/shape/IconRect.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// File.swift -// LiveProject -// -// Created by 倪路朋 on 6/25/25. -// - -import Foundation -import SwiftUI - -struct IconRect: Shape { - - func path(in rect: CGRect) -> Path { - var path = Path() - - let cornerRadius = rect.width * 0.08 - let borderRect = CGRect(origin: .zero, size: rect.size) - path.addRoundedRect(in: borderRect, cornerSize: CGSize(width: cornerRadius, height: cornerRadius)) - - return path - } -} diff --git a/LiveProject/tool/CameraHelper.swift b/LiveProject/tool/CameraHelper.swift deleted file mode 100644 index 299f2ce..0000000 --- a/LiveProject/tool/CameraHelper.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// CameraHelper.swift -// LiveProject -// -// Created by 倪路朋 on 6/27/25. -// -import UIKit -import AVFoundation - -class CameraHelper{ - -} diff --git a/LiveProject/tool/H264Encoder.swift b/LiveProject/tool/H264Encoder.swift new file mode 100644 index 0000000..8413eca --- /dev/null +++ b/LiveProject/tool/H264Encoder.swift @@ -0,0 +1,166 @@ +// +// H264Encoder.swift +// LiveProject +// +// Created by 倪路朋 on 6/28/25. +// +import Foundation +import AVFoundation +import VideoToolbox + +class H264Encoder { + private var session: VTCompressionSession? + private let width: Int + private let height: Int + private let fps: Int + private let bitrate: Int + + private let converter : PixelBufferConverter = PixelBufferConverter() + + var onEncoded: ((Data, CMTime, Bool) -> Void)? + + init(width: Int, height: Int, fps: Int, bitrate: Int) { + self.width = width + self.height = height + self.fps = fps + self.bitrate = bitrate + setupSession() + } + + private func setupSession() { + let status = VTCompressionSessionCreate( + allocator: nil, + width: Int32(width), + height: Int32(height), + codecType: kCMVideoCodecType_H264, + encoderSpecification: nil, + imageBufferAttributes: nil, + compressedDataAllocator: nil, + outputCallback: encodeCallback, + refcon: UnsafeMutableRawPointer(Unmanaged.passRetained(self).toOpaque()), + compressionSessionOut: &session + ) + + guard status == noErr, let session = session else { + print("❌ Failed to create session: \(status)") + return + } + + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: bitrate as CFTypeRef) + let frameInterval = Int(fps) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: frameInterval as CFTypeRef) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: frameInterval as CFTypeRef) + + VTCompressionSessionPrepareToEncodeFrames(session) + } + + func encode(pixelBuffer: CVPixelBuffer, pts: CMTime) { + guard let session = session else { + print("❌ Session is nil") + return + } + + let format = CVPixelBufferGetPixelFormatType(pixelBuffer) + let supportedFormats: [OSType] = [ + kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + ] + switch format{ + case kCVPixelFormatType_32BGRA: + print("32BGRA") + break; + case kCVPixelFormatType_32ARGB: + print("32ARGB") + break; + default: + print("????") + break; + } + + if let buffer = converter.convertBGRAtoNV12(pixelBuffer) { + print("converter \(decodeOSType(CVPixelBufferGetPixelFormatType(buffer)))") + let timestamp = CMTimeMake(value: Int64(CACurrentMediaTime() * 1000), timescale: 1000) + + var flags = VTEncodeInfoFlags() + let status = VTCompressionSessionEncodeFrame( + session, + imageBuffer:buffer , + presentationTimeStamp: pts, + duration: .invalid, + frameProperties: nil, + sourceFrameRefcon: nil, + infoFlagsOut: &flags + ) + if status != noErr { + print("❌ Encoding failed: \(status)") + } + } + + } + + func finish() { + guard let session = session else { return } + VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid) + } + + func invalidate() { + guard let session = session else { return } + VTCompressionSessionInvalidate(session) + self.session = nil + } + + func decodeOSType(_ format: OSType) -> String { + let characters = [ + UInt8((format >> 24) & 0xFF), + UInt8((format >> 16) & 0xFF), + UInt8((format >> 8) & 0xFF), + UInt8(format & 0xFF) + ] + return String(bytes: characters, encoding: .ascii) ?? "????" + } +} + +// MARK: - VideoToolbox Callback + +private func encodeCallback( + outputCallbackRefCon: UnsafeMutableRawPointer?, + sourceFrameRefCon: UnsafeMutableRawPointer?, + status: OSStatus, + infoFlags: VTEncodeInfoFlags, + sampleBuffer: CMSampleBuffer? +) { + guard + status == noErr, + let sampleBuffer = sampleBuffer, + CMSampleBufferDataIsReady(sampleBuffer), + let ref = outputCallbackRefCon + else { return } + + let encoder = Unmanaged<H264Encoder>.fromOpaque(ref).takeUnretainedValue() + + guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return } + + var length = 0 + var dataPointer: UnsafeMutablePointer<Int8>? + guard CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) == noErr else { + return + } + + let data = Data(bytes: dataPointer!, count: length) + + var isKeyframe = true + if let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false), + let dict = CFArrayGetValueAtIndex(attachments, 0) { + let d = unsafeBitCast(dict, to: CFDictionary.self) as NSDictionary + if let notSync = d[kCMSampleAttachmentKey_NotSync] as? Bool { + isKeyframe = !notSync + } + } + + let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + encoder.onEncoded?(data, pts, isKeyframe) + + // ⚠️ 这里必须 release 与 passRetained 配对 + Unmanaged<H264Encoder>.fromOpaque(ref).release() +} diff --git a/LiveProject/tool/MetalPixelConverter.swift b/LiveProject/tool/MetalPixelConverter.swift new file mode 100644 index 0000000..23a00d9 --- /dev/null +++ b/LiveProject/tool/MetalPixelConverter.swift @@ -0,0 +1,227 @@ +// +// MetalPixelConverter.swift +// LiveProject +// +// Created by 倪路朋 on 7/1/25. +// + +import Metal +import MetalKit +import CoreVideo + +final class MetalPixelConverter { + + // MARK: - Properties + private let device: MTLDevice + private var textureCache: CVMetalTextureCache? + private var commandQueue: MTLCommandQueue? + private var computePipeline: MTLComputePipelineState? + + // MARK: - Initialization + init?(metalDevice: MTLDevice? = MTLCreateSystemDefaultDevice()) { + guard let device = metalDevice else { + print("⚠️ Metal 不可用") + return nil + } + self.device = device + + // 1. 创建纹理缓存 + guard CVMetalTextureCacheCreate( + kCFAllocatorDefault, + nil, + device, + nil, + &textureCache + ) == kCVReturnSuccess else { + print("❌ 创建 Metal 纹理缓存失败") + return nil + } + + // 2. 创建命令队列 + self.commandQueue = device.makeCommandQueue() + + // 3. 加载着色器 + do { + self.computePipeline = try makeComputePipeline(device: device) + } catch { + print("❌ 加载着色器失败: \(error)") + return nil + } + } + + // MARK: - Public Methods + /// 将 BGRA CVPixelBuffer 转换为 NV12 CVPixelBuffer + func convertBGRAtoNV12( + _ inputBuffer: CVPixelBuffer, + completion: @escaping (Result<CVPixelBuffer, Error>) -> Void + ) { + // 0. 验证输入格式 + guard CVPixelBufferGetPixelFormatType(inputBuffer) == kCVPixelFormatType_32BGRA else { + completion(.failure(ConversionError.invalidInputFormat)) + return + } + + // 1. 创建输出 NV12 Buffer + let (width, height) = (CVPixelBufferGetWidth(inputBuffer), CVPixelBufferGetHeight(inputBuffer)) + guard let outputBuffer = createNV12PixelBuffer(width: width, height: height) else { + completion(.failure(ConversionError.outputBufferCreationFailed)) + return + } + + // 2. 异步处理(避免阻塞主线程) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + do { + let result = try self.performConversion( + inputBuffer: inputBuffer, + outputBuffer: outputBuffer + ) + DispatchQueue.main.async { + completion(.success(result)) + } + } catch { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + } + + // MARK: - Private Methods + private func makeComputePipeline(device: MTLDevice) throws -> MTLComputePipelineState { + // 1. 获取默认 Metal 库(需在项目中添加 .metal 文件) + let library = try device.makeDefaultLibrary(bundle: Bundle(for: Self.self)) + + // 2. 加载着色器函数 + guard let kernelFunction = library.makeFunction(name: "bgraToNV12") else { + throw ConversionError.shaderNotFound + } + + // 3. 创建计算管线 + return try device.makeComputePipelineState(function: kernelFunction) + } + + private func createNV12PixelBuffer(width: Int, height: Int) -> CVPixelBuffer? { + var pixelBuffer: CVPixelBuffer? + let status = CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + nil, + &pixelBuffer + ) + return status == kCVReturnSuccess ? pixelBuffer : nil + } + + private func performConversion( + inputBuffer: CVPixelBuffer, + outputBuffer: CVPixelBuffer + ) throws -> CVPixelBuffer { + // 1. 锁定缓冲区 + CVPixelBufferLockBaseAddress(inputBuffer, .readOnly) + CVPixelBufferLockBaseAddress(outputBuffer, []) + defer { + CVPixelBufferUnlockBaseAddress(inputBuffer, .readOnly) + CVPixelBufferUnlockBaseAddress(outputBuffer, []) + } + + // 2. 创建 Metal 纹理 + guard let textureCache = textureCache, + let inputTexture = createMetalTexture( + from: inputBuffer, + pixelFormat: .bgra8Unorm, + textureCache: textureCache + ), + let yTexture = createMetalTexture( + from: outputBuffer, + planeIndex: 0, + pixelFormat: .r8Unorm, + textureCache: textureCache + ), + let uvTexture = createMetalTexture( + from: outputBuffer, + planeIndex: 1, + pixelFormat: .rg8Unorm, + textureCache: textureCache + ) else { + throw ConversionError.textureCreationFailed + } + + // 3. 执行 Metal 计算 + guard let commandBuffer = commandQueue?.makeCommandBuffer(), + let encoder = commandBuffer.makeComputeCommandEncoder() else { + throw ConversionError.metalCommandFailed + } + + encoder.setComputePipelineState(computePipeline!) + encoder.setTexture(inputTexture, index: 0) + encoder.setTexture(yTexture, index: 1) + encoder.setTexture(uvTexture, index: 2) + + // 4. 调度计算任务 + let threadgroupSize = MTLSize(width: 16, height: 16, depth: 1) + let threadgroupCount = MTLSize( + width: (inputTexture.width + threadgroupSize.width - 1) / threadgroupSize.width, + height: (inputTexture.height + threadgroupSize.height - 1) / threadgroupSize.height, + depth: 1 + ) + encoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize) + encoder.endEncoding() + + // 5. 提交并等待完成 + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + + return outputBuffer + } + + private func createMetalTexture( + from pixelBuffer: CVPixelBuffer, + planeIndex: Int = 0, + pixelFormat: MTLPixelFormat, + textureCache: CVMetalTextureCache + ) -> MTLTexture? { + let width = CVPixelBufferGetWidthOfPlane(pixelBuffer, planeIndex) + let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, planeIndex) + + var cvMetalTexture: CVMetalTexture? + let status = CVMetalTextureCacheCreateTextureFromImage( + nil, + textureCache, + pixelBuffer, + nil, + pixelFormat, + width, + height, + planeIndex, + &cvMetalTexture + ) + + guard status == kCVReturnSuccess, let texture = cvMetalTexture else { + return nil + } + + return CVMetalTextureGetTexture(texture) + } + + // MARK: - Error Handling + enum ConversionError: Error, LocalizedError { + case invalidInputFormat + case outputBufferCreationFailed + case shaderNotFound + case textureCreationFailed + case metalCommandFailed + + var errorDescription: String? { + switch self { + case .invalidInputFormat: return "输入格式必须是 BGRA" + case .outputBufferCreationFailed: return "无法创建 NV12 输出缓冲区" + case .shaderNotFound: return "找不到 Metal 着色器" + case .textureCreationFailed: return "无法创建 Metal 纹理" + case .metalCommandFailed: return "Metal 命令执行失败" + } + } + } +} diff --git a/LiveProject/tool/MetalRenderer.swift b/LiveProject/tool/MetalRenderer.swift index a2aa4ba..61bd769 100644 --- a/LiveProject/tool/MetalRenderer.swift +++ b/LiveProject/tool/MetalRenderer.swift @@ -4,68 +4,99 @@ // 渲染工具 // Created by 倪路朋 on 6/26/25. // +import Foundation +import Metal import MetalKit +import AVFoundation class MetalRenderer: NSObject, MTKViewDelegate { - private var device: MTLDevice! - private var commandQueue: MTLCommandQueue! - private var textureCache: CVMetalTextureCache! + private let device: MTLDevice + private let commandQueue: MTLCommandQueue + private let ciContext: CIContext + private var currentPixelBuffer: CVPixelBuffer? + private let textureCache: CVMetalTextureCache - func setup(view: MTKView) { - self.device = view.device - self.commandQueue = device.makeCommandQueue() - CVMetalTextureCacheCreate(nil, nil, device, nil, &textureCache) + init(mtkView: MTKView) { + guard let device = MTLCreateSystemDefaultDevice(), + let commandQueue = device.makeCommandQueue() else { + fatalError("Unable to create Metal device or command queue") + } + + self.device = device + self.commandQueue = commandQueue + self.ciContext = CIContext(mtlDevice: device) + + var tmpCache: CVMetalTextureCache? + CVMetalTextureCacheCreate(nil, nil, device, nil, &tmpCache) + guard let textureCache = tmpCache else { + fatalError("Unable to create texture cache") + } + self.textureCache = textureCache + + super.init() + + // ✅ 设置驱动渲染的关键代码 + mtkView.device = device + mtkView.framebufferOnly = false + mtkView.isPaused = false + mtkView.enableSetNeedsDisplay = false + mtkView.delegate = self + print("MetalRenderer init") } - - func updateFrame(pixelBuffer: CVPixelBuffer) { - self.currentPixelBuffer = pixelBuffer - } - - func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} func draw(in view: MTKView) { - guard let drawable = view.currentDrawable, - let descriptor = view.currentRenderPassDescriptor, - let pixelBuffer = currentPixelBuffer else { return } + let size = view.drawableSize + //print("🧾 drawableSize = \(size)") - var textureRef: CVMetalTexture? - let width = CVPixelBufferGetWidth(pixelBuffer) - let height = CVPixelBufferGetHeight(pixelBuffer) - - let status = CVMetalTextureCacheCreateTextureFromImage( - nil, textureCache, pixelBuffer, nil, - .bgra8Unorm, width, height, 0, &textureRef) - - guard status == kCVReturnSuccess, - let cvTexture = textureRef, - let texture = CVMetalTextureGetTexture(cvTexture) else { return } - - let commandBuffer = commandQueue.makeCommandBuffer()! - let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)! - encoder.setFragmentTexture(texture, index: 0) - encoder.endEncoding() - - // 简单拷贝(不做 shader 处理) - let blitEncoder = commandBuffer.makeBlitCommandEncoder()! - let dstTexture = drawable.texture - if dstTexture.width != texture.width || dstTexture.height != texture.height { - print("❌ 尺寸不一致,无法 blit:src = \(texture.width)x\(texture.height), dst = \(dstTexture.width)x\(dstTexture.height)") + if !size.width.isFinite || !size.height.isFinite || size.width <= 0 || size.height <= 0 { + print("❌ 非法尺寸,跳过渲染 \(size)") return } - blitEncoder.copy(from: texture, - sourceSlice: 0, - sourceLevel: 0, - sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0), - sourceSize: MTLSize(width: width, height: height, depth: 1), + guard let drawable = view.currentDrawable, + let commandBuffer = commandQueue.makeCommandBuffer(), + let pixelBuffer = currentPixelBuffer else { + return + } + + let drawableSize = view.drawableSize + guard drawableSize.width > 0, drawableSize.height > 0 else { return } + + // 加方向修正:顺时针旋转90度 + var ciImage = CIImage(cvPixelBuffer: pixelBuffer).oriented(.right) + + // 等比缩放后居中 + let sourceExtent = ciImage.extent + let scaleX = drawableSize.width / sourceExtent.width + let scaleY = drawableSize.height / sourceExtent.height + let scale = min(scaleX, scaleY) + + let scaledImage = ciImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + + let xOffset = (drawableSize.width - scaledImage.extent.width) / 2 + let yOffset = (drawableSize.height - scaledImage.extent.height) / 2 + let translatedImage = scaledImage.transformed(by: CGAffineTransform(translationX: xOffset, y: yOffset)) + + // 渲染 + ciContext.render(translatedImage, to: drawable.texture, - destinationSlice: 0, - destinationLevel: 0, - destinationOrigin: MTLOrigin(x: 0, y: 0, z: 0)) - blitEncoder.endEncoding() + commandBuffer: commandBuffer, + bounds: CGRect(origin: .zero, size: drawableSize), + colorSpace: CGColorSpaceCreateDeviceRGB()) commandBuffer.present(drawable) commandBuffer.commit() - print("绘制画面") + //print("绘制画面") + } + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { + // No-op: Handle size change if needed + } + + func display(pixelBuffer: CVPixelBuffer) { + self.currentPixelBuffer = pixelBuffer + //print("display") + //刷新 + } } diff --git a/LiveProject/tool/PixelBufferConverter.swift b/LiveProject/tool/PixelBufferConverter.swift new file mode 100644 index 0000000..bd88d1f --- /dev/null +++ b/LiveProject/tool/PixelBufferConverter.swift @@ -0,0 +1,44 @@ +// +// PixelBufferConverter.swift +// LiveProject +// +// Created by 倪路朋 on 6/30/25. +// + +import Foundation +import CoreImage +import CoreVideo + + +class PixelBufferConverter { + private let ciContext = CIContext() + + func convertBGRAtoNV12(_ srcBuffer: CVPixelBuffer) -> CVPixelBuffer? { + let width = CVPixelBufferGetWidth(srcBuffer) + let height = CVPixelBufferGetHeight(srcBuffer) + + let attrs: [CFString: Any] = [ + kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, + kCVPixelBufferWidthKey: width, + kCVPixelBufferHeightKey: height, + kCVPixelBufferIOSurfacePropertiesKey: [:] + ] + + var dstBuffer: CVPixelBuffer? + let status = CVPixelBufferCreate(nil, width, height, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, attrs as CFDictionary, &dstBuffer) + + guard status == kCVReturnSuccess, let output = dstBuffer else { + print("❌ 创建 NV12 失败,状态: \(status)") + return nil + } + + // 渲染 + let ciImage = CIImage(cvPixelBuffer: srcBuffer) + + CVPixelBufferLockBaseAddress(output, []) + ciContext.render(ciImage, to: output) + CVPixelBufferUnlockBaseAddress(output, []) + + return output + } +} diff --git a/LiveProject/views/FlowLayout.swift b/LiveProject/views/FlowLayout.swift index 151cd68..038d158 100644 --- a/LiveProject/views/FlowLayout.swift +++ b/LiveProject/views/FlowLayout.swift @@ -5,163 +5,89 @@ // Created by 倪路朋 on 6/26/25. // import SwiftUI -import SwiftUI -/// 完全自定义的自动换行布局容器 -struct FlowLayout<Data: RandomAccessCollection, Content: View>: View where Data.Element: Hashable { - // MARK: - 属性 - - /// 要显示的数据集合 - let data: Data - /// 水平间距 - let horizontalSpacing: CGFloat - /// 垂直间距 - let verticalSpacing: CGFloat - /// 对齐方式 - let alignment: HorizontalAlignment - /// 内容构建闭包 - let content: (Data.Element) -> Content - /// 总高度状态 - @State private var totalHeight: CGFloat = 0 - - // MARK: - 初始化 - - /// 初始化FlowLayout - /// - Parameters: - /// - data: 要显示的数据集合 - /// - horizontalSpacing: 水平间距,默认为8 - /// - verticalSpacing: 垂直间距,默认为8 - /// - alignment: 对齐方式,默认为.leading - /// - content: 内容构建闭包 - init( - _ data: Data, - horizontalSpacing: CGFloat = 8, - verticalSpacing: CGFloat = 8, - alignment: HorizontalAlignment = .leading, - @ViewBuilder content: @escaping (Data.Element) -> Content - ) { - self.data = data - self.horizontalSpacing = horizontalSpacing - self.verticalSpacing = verticalSpacing - self.alignment = alignment - self.content = content - } - - // MARK: - 主体视图 - - var body: some View { - GeometryReader { geometry in - self.contentView(in: geometry) - .background( - HeightReader(height: $totalHeight) - ) - } - .frame(height: totalHeight) - } - - // MARK: - 私有方法 - - /// 构建内容视图 - private func contentView(in geometry: GeometryProxy) -> some View { +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + var lineSpacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { var width: CGFloat = 0 var height: CGFloat = 0 - var lastHeight: CGFloat = 0 - - return ZStack(alignment: Alignment(horizontal: alignment, vertical: .top)) { - ForEach(data.map { $0 }, id: \.self) { item in - content(item) - .padding(.trailing, horizontalSpacing) - .padding(.bottom, verticalSpacing) - .alignmentGuide(.leading) { dimensions in - // 检查是否需要换行 - if abs(width - dimensions.width) > geometry.size.width { - width = 0 - height += lastHeight + verticalSpacing - } - - let result = width - - // 更新宽度计算 - if item == data.last { - width = 0 // 重置为0,最后一项 - } else { - width -= dimensions.width + horizontalSpacing - } - - // 记录当前行高度 - lastHeight = dimensions.height - return result - } - .alignmentGuide(.top) { dimensions in - let result = height - - // 如果是最后一项,更新总高度 - if item == data.last { - height += lastHeight + verticalSpacing - } - - return result - } + var currentLineWidth: CGFloat = 0 + var currentLineHeight: CGFloat = 0 + let maxWidth = proposal.width ?? .infinity + + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if currentLineWidth + size.width > maxWidth { + width = max(width, currentLineWidth) + height += currentLineHeight + lineSpacing + currentLineWidth = size.width + currentLineHeight = size.height + } else { + currentLineWidth += size.width + spacing + currentLineHeight = max(currentLineHeight, size.height) } } + + width = max(width, currentLineWidth) + height += currentLineHeight + + return CGSize(width: width, height: height) } -} -// MARK: - 高度读取器 + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x: CGFloat = 0 + var y: CGFloat = 0 + var lineHeight: CGFloat = 0 -/// 用于读取视图高度的辅助视图 -private struct HeightReader: View { - @Binding var height: CGFloat - - var body: some View { - GeometryReader { geometry in - Color.clear - .preference( - key: HeightPreferenceKey.self, - value: geometry.size.height - ) - } - .onPreferenceChange(HeightPreferenceKey.self) { newHeight in - DispatchQueue.main.async { - self.height = newHeight + for view in subviews { + let size = view.sizeThatFits(.unspecified) + + if x + size.width > bounds.width { + x = 0 + y += lineHeight + lineSpacing + lineHeight = 0 } + + view.place( + at: CGPoint(x: bounds.minX + x, y: bounds.minY + y), + proposal: ProposedViewSize(width: size.width, height: size.height) + ) + + x += size.width + spacing + lineHeight = max(lineHeight, size.height) } } } - -// MARK: - 高度偏好键 - -/// 用于传递高度值的PreferenceKey -private struct HeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() - } -} - // MARK: - 使用示例 struct FlowLayoutExample: View { let tags = [ - "Swift", "SwiftUI", "UIKit", "Combine", "Core Data", - "Xcode", "Interface Builder", "Core Animation", "ARKit", - "Metal", "Core ML", "Vision", "MapKit", "CloudKit" + "Swift" ] @State private var newTag = "" @State private var customTags = ["自定义标签1", "自定义标签2"] var body: some View { - VStack { - FlowLayout(customTags + tags, horizontalSpacing: 10, verticalSpacing: 10) { tag in - MButton(text:tag){ + VStack{ + VStack(spacing: 20) { + FlowLayout(){ - } + ForEach(tags, id: \.self) { item in + Text(item) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.2)) + .cornerRadius(8) + } + }.frame(alignment:.leading) + .background(Color.red) } - .padding() - .animation(.default, value: customTags) + .frame(maxWidth: .infinity,alignment:.leading) } + .background(Color.black) } private func addTag() { diff --git a/LiveProject/views/LTextField.swift b/LiveProject/views/LTextField.swift new file mode 100644 index 0000000..a945b34 --- /dev/null +++ b/LiveProject/views/LTextField.swift @@ -0,0 +1,73 @@ +// +// LTextField.swift +// LiveProject +// +// Created by 倪路朋 on 7/1/25. +// + + +import SwiftUI + +class LText:ObservableObject{ + @Published var input = "" + + func update( _ text : String){ + input = text; + } +} + +struct LTextField: View { + + var hint:String = "请输入RTMP直播地址"; + @EnvironmentObject var text:LText; + @State var onFocus = false; + @FocusState var isfocused:Bool + @State var inputType : UIKeyboardType = UIKeyboardType.default; + @State var strl = ""; + var lines = 1; + + var body: some View { + + VStack(alignment: .leading) {// iOS + let binding = Binding<String>(get: { + //print(self.text.input); + return text.input + }, set: { str in + text.update(str) + }) + + TextField(hint,text: binding){ change in + print(hint+" \(change)") + self.onFocus = change + } onCommit: { + print("onCommit") + } + .font(Font.system(size: 16)) + .focused($isfocused) + .foregroundColor(Color.colorText) + .padding(.leading,24) + .padding(.trailing,24) + .cornerRadius(12) + .frame(height: 60) + .keyboardType(inputType) + .onSubmit { + print("onSubmit") + } + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.colorText, lineWidth: 2) + ) + }.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) + .onTapGesture { + isfocused = true + print("Click ATextField button") + //textField.focused(SwiftUI.FocusState<Bool>) + } + } +} + +struct LTextField_Previews: PreviewProvider { + static var previews: some View { + LTextField().environmentObject(LText()) + } +} diff --git a/LiveProject/views/MButton.swift b/LiveProject/views/MButton.swift index ce8b915..54b2d2d 100644 --- a/LiveProject/views/MButton.swift +++ b/LiveProject/views/MButton.swift @@ -12,7 +12,7 @@ var valid :ValidState = .VALID; - var icon : (any View)? = nil; + var icon : IconInfo? = nil; var text : String? = nil; @@ -30,19 +30,23 @@ ZStack() {// iOS HStack() {// iOS - if let iconView = icon { - AnyView(iconView) + if let info = icon{ + Image(systemName: info.name) + .resizable() + .frame(width: info.size.width, height: info.size.height) + .aspectRatio(contentMode: .fit) + .foregroundColor(Color.white) } if let str = text { Text(str) .font(Font.system(size: 16)) - .foregroundColor(Color.init("ColorWhite")) + .foregroundColor(Color.white) .frame(width: .infinity, height: 40) } }.frame(minWidth: 40, maxHeight: 40).padding(EdgeInsets(top: 0, leading: 15, bottom: 0, trailing: 15)) }.frame(maxHeight: 40).background( RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(Color.init(valid == .INVALID ?"ColorGray":"ColorText")) + .fill(valid == .INVALID ? Color.colorGray : Color.colorText) ) }.buttonStyle( TextBtnStyle()) @@ -51,6 +55,6 @@ struct MButton_Previews: PreviewProvider { static var previews: some View { - MButton(icon: IconPortrait()) + MButton(icon: Icons.IMAGE_MUTE) } } diff --git a/LiveProject/views/TitleBarView.swift b/LiveProject/views/TitleBarView.swift index 7cfce53..d063a17 100644 --- a/LiveProject/views/TitleBarView.swift +++ b/LiveProject/views/TitleBarView.swift @@ -12,7 +12,7 @@ @Environment(\.presentationMode) var presentationMode var title = "" - var iconBack = IconBack(); + var iconBack = Icons.BACK; var imgRight = ""; var titleColor = Color.colorText @@ -24,7 +24,11 @@ print("Click back button") self.presentationMode.wrappedValue.dismiss() }) { - iconBack.stroke(Color.primary, lineWidth: 2.5).frame(width: 18,height: 14) + Image(systemName: iconBack.name) + .resizable() + .frame(width: iconBack.size.width, height: iconBack.size.height) + .aspectRatio(contentMode: .fit) + .foregroundColor(Color.white) } Spacer() Text(title).foregroundColor(titleColor) diff --git a/LiveProject/views/VideoRendererView.swift b/LiveProject/views/VideoRendererView.swift index 67fdc70..d157f06 100644 --- a/LiveProject/views/VideoRendererView.swift +++ b/LiveProject/views/VideoRendererView.swift @@ -8,19 +8,33 @@ import MetalKit struct VideoRendererView: UIViewRepresentable { - let renderer: MetalRenderer // 自定义 Metal 渲染器,支持传入 RGBA/YUV 数据帧 + @Binding var pixelBuffer: CVPixelBuffer? - func makeUIView(context: Context) -> MTKView { - let view = MTKView() - view.device = MTLCreateSystemDefaultDevice() - view.colorPixelFormat = .bgra8Unorm - view.clearColor = MTLClearColor(red: 0.2, green: 0.5, blue: 0.7, alpha: 1.0) - view.delegate = renderer - view.isPaused = false - view.enableSetNeedsDisplay = false - renderer.setup(view: view) - return view + //用 Coordinator 缓存实例 + func makeCoordinator() -> Coordinator { + return Coordinator() } - func updateUIView(_ uiView: MTKView, context: Context) {} + func makeUIView(context: Context) -> MTKView { + return context.coordinator.mtkView + } + + func updateUIView(_ uiView: MTKView, context: Context) { + if let buffer = pixelBuffer { + //print("updateUIView") + context.coordinator.renderer.display(pixelBuffer: buffer) + } + } + + class Coordinator { + let mtkView: MTKView + let renderer: MetalRenderer + + init() { + print("📦 MetalRendererWrapper 初始化了") + mtkView = MTKView() + renderer = MetalRenderer(mtkView: mtkView) + } + } } + -- Gitblit v1.9.1