20 changed files with 650 additions and 256 deletions
@ -0,0 +1,291 @@ |
|||||
|
import UIKit |
||||
|
import AVFoundation |
||||
|
|
||||
|
/** |
||||
|
* 带内边距的标签 |
||||
|
*/ |
||||
|
class PaddingLabel: UILabel { |
||||
|
var padding: UIEdgeInsets |
||||
|
|
||||
|
init(padding: UIEdgeInsets) { |
||||
|
self.padding = padding |
||||
|
super.init(frame: .zero) |
||||
|
} |
||||
|
|
||||
|
required init?(coder: NSCoder) { |
||||
|
self.padding = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) |
||||
|
super.init(coder: coder) |
||||
|
} |
||||
|
|
||||
|
override func drawText(in rect: CGRect) { |
||||
|
super.drawText(in: rect.inset(by: padding)) |
||||
|
} |
||||
|
|
||||
|
override var intrinsicContentSize: CGSize { |
||||
|
let size = super.intrinsicContentSize |
||||
|
return CGSize(width: size.width + padding.left + padding.right, |
||||
|
height: size.height + padding.top + padding.bottom) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 对话画中画视图 |
||||
|
* |
||||
|
* 用于在画中画模式下显示用户和AI的对话内容 |
||||
|
*/ |
||||
|
@available(iOS 13.0, *) |
||||
|
class DialogPipView: UIView { |
||||
|
|
||||
|
// 视频播放器图层(必须有,否则画中画无法工作) |
||||
|
private var playerLayer: AVPlayerLayer? |
||||
|
private var player: AVPlayer? |
||||
|
|
||||
|
// 对话显示视图 |
||||
|
private let dialogView = UIView() |
||||
|
|
||||
|
// 用户消息标签 |
||||
|
private let userMessageLabel: PaddingLabel = { |
||||
|
let label = PaddingLabel(padding: UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)) |
||||
|
label.font = UIFont.systemFont(ofSize: 14) |
||||
|
label.textColor = UIColor.white |
||||
|
label.numberOfLines = 0 |
||||
|
label.backgroundColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 0.7) |
||||
|
label.layer.cornerRadius = 8 |
||||
|
label.layer.masksToBounds = true |
||||
|
label.textAlignment = .left |
||||
|
label.isHidden = true |
||||
|
return label |
||||
|
}() |
||||
|
|
||||
|
// AI助手消息标签 |
||||
|
private let assistantMessageLabel: PaddingLabel = { |
||||
|
let label = PaddingLabel(padding: UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)) |
||||
|
label.font = UIFont.systemFont(ofSize: 14) |
||||
|
label.textColor = UIColor.white |
||||
|
label.numberOfLines = 0 |
||||
|
label.backgroundColor = UIColor(red: 0.0, green: 0.4, blue: 0.8, alpha: 0.7) |
||||
|
label.layer.cornerRadius = 8 |
||||
|
label.layer.masksToBounds = true |
||||
|
label.textAlignment = .left |
||||
|
label.isHidden = true |
||||
|
return label |
||||
|
}() |
||||
|
|
||||
|
// 初始化 |
||||
|
override init(frame: CGRect) { |
||||
|
super.init(frame: frame) |
||||
|
setupView() |
||||
|
} |
||||
|
|
||||
|
required init?(coder: NSCoder) { |
||||
|
super.init(coder: coder) |
||||
|
setupView() |
||||
|
} |
||||
|
|
||||
|
// 设置视图 |
||||
|
private func setupView() { |
||||
|
backgroundColor = UIColor.black |
||||
|
|
||||
|
// 添加对话视图 |
||||
|
addSubview(dialogView) |
||||
|
dialogView.frame = bounds |
||||
|
dialogView.autoresizingMask = [.flexibleWidth, .flexibleHeight] |
||||
|
|
||||
|
// 添加用户消息标签 |
||||
|
dialogView.addSubview(userMessageLabel) |
||||
|
userMessageLabel.translatesAutoresizingMaskIntoConstraints = false |
||||
|
NSLayoutConstraint.activate([ |
||||
|
userMessageLabel.leadingAnchor.constraint(equalTo: dialogView.leadingAnchor, constant: 8), |
||||
|
userMessageLabel.trailingAnchor.constraint(equalTo: dialogView.trailingAnchor, constant: -8), |
||||
|
userMessageLabel.topAnchor.constraint(equalTo: dialogView.topAnchor, constant: 10), |
||||
|
]) |
||||
|
|
||||
|
// 添加AI助手消息标签 |
||||
|
dialogView.addSubview(assistantMessageLabel) |
||||
|
assistantMessageLabel.translatesAutoresizingMaskIntoConstraints = false |
||||
|
NSLayoutConstraint.activate([ |
||||
|
assistantMessageLabel.leadingAnchor.constraint(equalTo: dialogView.leadingAnchor, constant: 8), |
||||
|
assistantMessageLabel.trailingAnchor.constraint(equalTo: dialogView.trailingAnchor, constant: -8), |
||||
|
assistantMessageLabel.topAnchor.constraint(equalTo: userMessageLabel.bottomAnchor, constant: 10), |
||||
|
assistantMessageLabel.bottomAnchor.constraint(lessThanOrEqualTo: dialogView.bottomAnchor, constant: -10) |
||||
|
]) |
||||
|
} |
||||
|
|
||||
|
// 创建并返回视频图层 |
||||
|
func setupDialogLayer() -> AVPlayerLayer { |
||||
|
// 创建无声视频,用于支持画中画功能 |
||||
|
let playerItem = createSilentVideoPlayerItem() |
||||
|
player = AVPlayer(playerItem: playerItem) |
||||
|
player?.actionAtItemEnd = .none |
||||
|
player?.play() |
||||
|
|
||||
|
// 创建视频图层 |
||||
|
let layer = AVPlayerLayer(player: player) |
||||
|
layer.frame = bounds |
||||
|
layer.videoGravity = .resizeAspectFill |
||||
|
|
||||
|
// 将视频图层放在最底层 |
||||
|
self.layer.insertSublayer(layer, at: 0) |
||||
|
playerLayer = layer |
||||
|
|
||||
|
// 注册通知,确保视频循环播放 |
||||
|
NotificationCenter.default.addObserver(self, |
||||
|
selector: #selector(playerItemDidReachEnd), |
||||
|
name: .AVPlayerItemDidPlayToEndTime, |
||||
|
object: player?.currentItem) |
||||
|
|
||||
|
return layer |
||||
|
} |
||||
|
|
||||
|
// 创建无声视频用于支持画中画 |
||||
|
private func createSilentVideoPlayerItem() -> AVPlayerItem { |
||||
|
// 创建一个简单的黑色视频,足够支持画中画但不会占用太多资源 |
||||
|
let composition = AVMutableComposition() |
||||
|
|
||||
|
// 添加视频轨道 |
||||
|
let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) |
||||
|
|
||||
|
// 创建一个简单的黑色视频,持续10秒 |
||||
|
let videoSize = CGSize(width: 640, height: 480) |
||||
|
let duration = CMTime(seconds: 10, preferredTimescale: 600) |
||||
|
|
||||
|
// 创建一个黑色图像 |
||||
|
UIGraphicsBeginImageContextWithOptions(videoSize, true, 0) |
||||
|
UIColor.black.setFill() |
||||
|
UIRectFill(CGRect(origin: .zero, size: videoSize)) |
||||
|
let blackImage = UIGraphicsGetImageFromCurrentImageContext() |
||||
|
UIGraphicsEndImageContext() |
||||
|
|
||||
|
// 创建黑色图像的视频 |
||||
|
let settings = [ |
||||
|
AVVideoCodecKey: AVVideoCodecType.h264, |
||||
|
AVVideoWidthKey: videoSize.width, |
||||
|
AVVideoHeightKey: videoSize.height |
||||
|
] as [String : Any] |
||||
|
|
||||
|
let writerInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings) |
||||
|
let adaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: writerInput, sourcePixelBufferAttributes: nil) |
||||
|
|
||||
|
if let pixelBuffer = createPixelBuffer(fromImage: blackImage!, size: videoSize) { |
||||
|
let attributes: [String: Any] = [ |
||||
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB, |
||||
|
kCVPixelBufferWidthKey as String: videoSize.width, |
||||
|
kCVPixelBufferHeightKey as String: videoSize.height |
||||
|
] |
||||
|
|
||||
|
// 创建一个5秒的黑色视频 |
||||
|
let assetWriter = try! AVAssetWriter(outputURL: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("temp.mov"), fileType: .mov) |
||||
|
assetWriter.add(writerInput) |
||||
|
assetWriter.startWriting() |
||||
|
assetWriter.startSession(atSourceTime: .zero) |
||||
|
|
||||
|
let frameCount = 50 // 5秒,10fps |
||||
|
let frameDuration = CMTime(value: 1, timescale: 10) |
||||
|
|
||||
|
writerInput.requestMediaDataWhenReady(on: DispatchQueue.global()) { |
||||
|
for i in 0..<frameCount { |
||||
|
while !writerInput.isReadyForMoreMediaData {} |
||||
|
|
||||
|
let presentationTime = CMTime(value: CMTimeValue(i), timescale: 10) |
||||
|
adaptor.append(pixelBuffer, withPresentationTime: presentationTime) |
||||
|
} |
||||
|
|
||||
|
writerInput.markAsFinished() |
||||
|
assetWriter.finishWriting {} |
||||
|
} |
||||
|
|
||||
|
// 等待视频写入完成 |
||||
|
while assetWriter.status == .writing {} |
||||
|
|
||||
|
// 创建AVPlayerItem |
||||
|
let asset = AVAsset(url: assetWriter.outputURL) |
||||
|
return AVPlayerItem(asset: asset) |
||||
|
} |
||||
|
|
||||
|
// 如果上面的方法失败,创建一个简单的默认项 |
||||
|
let emptyItem = AVPlayerItem(url: URL(fileURLWithPath: "")) |
||||
|
return emptyItem |
||||
|
} |
||||
|
|
||||
|
// 从UIImage创建像素缓冲区 |
||||
|
private func createPixelBuffer(fromImage image: UIImage, size: CGSize) -> CVPixelBuffer? { |
||||
|
var pixelBuffer: CVPixelBuffer? |
||||
|
let attrs = [ |
||||
|
kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue, |
||||
|
kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue |
||||
|
] as CFDictionary |
||||
|
|
||||
|
let status = CVPixelBufferCreate( |
||||
|
kCFAllocatorDefault, |
||||
|
Int(size.width), |
||||
|
Int(size.height), |
||||
|
kCVPixelFormatType_32ARGB, |
||||
|
attrs, |
||||
|
&pixelBuffer |
||||
|
) |
||||
|
|
||||
|
guard status == kCVReturnSuccess, let buffer = pixelBuffer else { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
CVPixelBufferLockBaseAddress(buffer, []) |
||||
|
let context = CGContext( |
||||
|
data: CVPixelBufferGetBaseAddress(buffer), |
||||
|
width: Int(size.width), |
||||
|
height: Int(size.height), |
||||
|
bitsPerComponent: 8, |
||||
|
bytesPerRow: CVPixelBufferGetBytesPerRow(buffer), |
||||
|
space: CGColorSpaceCreateDeviceRGB(), |
||||
|
bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue |
||||
|
) |
||||
|
|
||||
|
context?.draw(image.cgImage!, in: CGRect(x: 0, y: 0, width: size.width, height: size.height)) |
||||
|
CVPixelBufferUnlockBaseAddress(buffer, []) |
||||
|
|
||||
|
return buffer |
||||
|
} |
||||
|
|
||||
|
// 视频播放结束处理 |
||||
|
@objc private func playerItemDidReachEnd(notification: Notification) { |
||||
|
// 循环播放视频 |
||||
|
player?.seek(to: .zero) |
||||
|
player?.play() |
||||
|
} |
||||
|
|
||||
|
// 更新对话内容 |
||||
|
func updateDialogContent(userMessage: String, assistantMessage: String) { |
||||
|
// 设置用户消息 |
||||
|
if !userMessage.isEmpty { |
||||
|
userMessageLabel.text = "用户: \(userMessage)" |
||||
|
userMessageLabel.isHidden = false |
||||
|
} else { |
||||
|
userMessageLabel.isHidden = true |
||||
|
} |
||||
|
|
||||
|
// 设置AI助手消息 |
||||
|
if !assistantMessage.isEmpty { |
||||
|
assistantMessageLabel.text = "AI助手: \(assistantMessage)" |
||||
|
assistantMessageLabel.isHidden = false |
||||
|
} else { |
||||
|
assistantMessageLabel.isHidden = true |
||||
|
} |
||||
|
|
||||
|
// 更新布局 |
||||
|
setNeedsLayout() |
||||
|
} |
||||
|
|
||||
|
// 布局子视图 |
||||
|
override func layoutSubviews() { |
||||
|
super.layoutSubviews() |
||||
|
|
||||
|
// 调整视频图层大小 |
||||
|
playerLayer?.frame = bounds |
||||
|
} |
||||
|
|
||||
|
// 释放资源 |
||||
|
deinit { |
||||
|
NotificationCenter.default.removeObserver(self) |
||||
|
player?.pause() |
||||
|
player = nil |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue