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