Files
markbaseengine/Sources/MarkBaseServer/VideoProcessor.swift
T
MarkBase Admin 8a66b9086a
CI / build (push) Waiting to run
CI / unit-tests (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
v2: Initial clean branch with unit tests + CI/CD pipeline
- Started from ac75faa (initial E4B-MarkBase integration)
- Kept Sources/ (all engine code) + Package.swift + .gitignore
- Removed all ad-hoc tests, documentation, scripts, Python files
- Added Tests/00_Unit/ (MathTest, TokenizerTest, SamplerTest)
- Added .gitea/workflows/ci.yaml (build + unit tests + lint)
- Added Scripts/check_resources.sh (memory-aware test runner)
- Added Tests/Manifest.json (resource requirements for all tests)
- Focus: 4-bit quantized models only
2026-07-05 13:29:25 +08:00

409 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Accelerate
import AVFoundation
import CoreVideo
import Foundation
// ─────────────────────────────────────────────────────────────
// Video Processor — Extract frames + audio from video files
// ─────────────────────────────────────────────────────────────
public struct VideoFrame {
public let index: Int
public let timestamp: CMTime
public let pixelBuffer: CVPixelBuffer
}
public struct VideoData {
public let frames: [VideoFrame]
public let audioSamples: [Float]
public let sampleRate: Int
public let duration: CMTime
public let naturalSize: CGSize
public let estimatedFrameRate: Float
}
public enum VideoProcessor {
public struct Config {
public let maxFrames: Int
public let targetFPS: Float
public let audioSampleRate: Int
public let sceneThreshold: Double // 0…1; 0 = fixed FPS, >0 = scene detection
public init(maxFrames: Int = 64, targetFPS: Float = 2.0,
audioSampleRate: Int = 16000, sceneThreshold: Double = 0.15) {
self.maxFrames = maxFrames
self.targetFPS = targetFPS
self.audioSampleRate = audioSampleRate
self.sceneThreshold = sceneThreshold
}
}
/// Compute luminance histogram difference between two pixel buffers (0…1, higher = more different).
public static func sceneDiff(_ a: CVPixelBuffer, _ b: CVPixelBuffer) -> Double {
CVPixelBufferLockBaseAddress(a, .readOnly)
CVPixelBufferLockBaseAddress(b, .readOnly)
defer {
CVPixelBufferUnlockBaseAddress(a, .readOnly)
CVPixelBufferUnlockBaseAddress(b, .readOnly)
}
let w = min(CVPixelBufferGetWidth(a), CVPixelBufferGetWidth(b))
let h = min(CVPixelBufferGetHeight(a), CVPixelBufferGetHeight(b))
let rowA = CVPixelBufferGetBytesPerRow(a)
let rowB = CVPixelBufferGetBytesPerRow(b)
guard let baseA = CVPixelBufferGetBaseAddress(a),
let baseB = CVPixelBufferGetBaseAddress(b) else { return 0 }
let ptrA = baseA.assumingMemoryBound(to: UInt8.self)
let ptrB = baseB.assumingMemoryBound(to: UInt8.self)
var histA = [Double](repeating: 0, count: 256)
var histB = [Double](repeating: 0, count: 256)
let total = w * h
for y in 0..<h {
for x in 0..<w {
// Luminance ≈ green channel (BGRA → index 1)
histA[Int(ptrA[y * rowA + x * 4 + 1])] += 1
histB[Int(ptrB[y * rowB + x * 4 + 1])] += 1
}
}
// Normalise and compute intersection
var diff: Double = 0
for i in 0..<256 {
let normA = histA[i] / Double(total)
let normB = histB[i] / Double(total)
diff += abs(normA - normB)
}
return diff / 2 // 0..1
}
public static func process(url: URL, config: Config = Config()) async throws -> VideoData {
let asset = AVURLAsset(url: url)
let duration = try await asset.load(.duration)
let videoTracks = try await asset.loadTracks(withMediaType: .video)
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
guard let videoTrack = videoTracks.first else {
throw MarkBaseError.invalidParameter(parameter: "url", message: "No video track found")
}
let naturalSize = try await videoTrack.load(.naturalSize)
let nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
// Read audio samples concurrently
let audioSamples: [Float]
if let audioTrack = audioTracks.first {
audioSamples = try readAudioTrack(audioTrack, sampleRate: config.audioSampleRate)
} else {
audioSamples = []
}
// Read video frames (scene detection or fixed FPS)
let frames: [VideoFrame]
if config.sceneThreshold > 0 {
frames = try await readVideoTrackSceneDetect(
videoTrack,
duration: duration,
threshold: config.sceneThreshold,
maxFrames: config.maxFrames
)
print(" Scene detection: \(frames.count) keyframes")
} else {
frames = try await readVideoTrack(
videoTrack,
duration: duration,
nominalFrameRate: nominalFrameRate,
targetFPS: config.targetFPS,
maxFrames: config.maxFrames
)
}
return VideoData(
frames: frames,
audioSamples: audioSamples,
sampleRate: config.audioSampleRate,
duration: duration,
naturalSize: naturalSize,
estimatedFrameRate: nominalFrameRate
)
}
// ── Video reading ─────────────────────────────────
private static func readVideoTrack(
_ track: AVAssetTrack,
duration: CMTime,
nominalFrameRate: Float,
targetFPS: Float,
maxFrames: Int
) async throws -> [VideoFrame] {
let reader = try AVAssetReader(asset: track.asset!)
let formatDescriptions = try await track.load(.formatDescriptions)
let pixelFormat: OSType
if let firstDesc = formatDescriptions.first {
pixelFormat = CMFormatDescriptionGetMediaSubType(firstDesc)
} else {
pixelFormat = kCVPixelFormatType_32BGRA
}
let settings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: pixelFormat,
kCVPixelBufferMetalCompatibilityKey as String: true,
]
let output = AVAssetReaderTrackOutput(track: track, outputSettings: settings)
reader.add(output)
reader.startReading()
defer {
if reader.status == .reading {
reader.cancelReading()
}
}
let stepSeconds = CMTime(value: CMTimeValue(1.0 / targetFPS), timescale: CMTimeScale(targetFPS * 100))
.seconds
var frames: [VideoFrame] = []
var lastSampleTime: Double = -stepSeconds
while reader.status == .reading, frames.count < maxFrames {
guard let sampleBuffer = output.copyNextSampleBuffer() else { break }
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let timeSeconds = presentationTime.seconds
if timeSeconds - lastSampleTime >= stepSeconds {
if let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) {
frames.append(VideoFrame(
index: frames.count,
timestamp: presentationTime,
pixelBuffer: pixelBuffer
))
}
lastSampleTime = timeSeconds
}
}
return frames
}
// ── Scene-detection reading ─────────────────────────
private static func readVideoTrackSceneDetect(
_ track: AVAssetTrack,
duration: CMTime,
threshold: Double,
maxFrames: Int
) async throws -> [VideoFrame] {
let reader = try AVAssetReader(asset: track.asset!)
let formatDescriptions = try await track.load(.formatDescriptions)
let pixelFormat: OSType
if let firstDesc = formatDescriptions.first {
pixelFormat = CMFormatDescriptionGetMediaSubType(firstDesc)
} else {
pixelFormat = kCVPixelFormatType_32BGRA
}
let settings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: pixelFormat,
kCVPixelBufferMetalCompatibilityKey as String: true,
]
let output = AVAssetReaderTrackOutput(track: track, outputSettings: settings)
reader.add(output)
reader.startReading()
defer {
if reader.status == .reading { reader.cancelReading() }
}
var frames: [VideoFrame] = []
var prevBuffer: CVPixelBuffer?
while reader.status == .reading, frames.count < maxFrames {
guard let sampleBuffer = output.copyNextSampleBuffer() else { break }
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { continue }
if let prev = prevBuffer {
let diff = sceneDiff(prev, pixelBuffer)
if diff >= threshold || frames.isEmpty {
frames.append(VideoFrame(
index: frames.count,
timestamp: CMSampleBufferGetPresentationTimeStamp(sampleBuffer),
pixelBuffer: pixelBuffer
))
}
} else {
frames.append(VideoFrame(
index: frames.count,
timestamp: CMSampleBufferGetPresentationTimeStamp(sampleBuffer),
pixelBuffer: pixelBuffer
))
}
prevBuffer = pixelBuffer
}
return frames
}
// ── Audio reading ─────────────────────────────────
private static func readAudioTrack(_ track: AVAssetTrack, sampleRate: Int) throws -> [Float] {
let reader = try AVAssetReader(asset: track.asset!)
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVLinearPCMIsFloatKey: true,
AVLinearPCMBitDepthKey: 32,
AVNumberOfChannelsKey: 1,
AVSampleRateKey: sampleRate,
]
let output = AVAssetReaderTrackOutput(track: track, outputSettings: settings)
reader.add(output)
reader.startReading()
defer {
if reader.status == .reading {
reader.cancelReading()
}
}
var samples: [Float] = []
while reader.status == .reading {
guard let sampleBuffer = output.copyNextSampleBuffer() else { break }
guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { continue }
let length = CMBlockBufferGetDataLength(blockBuffer)
var data = [Float](repeating: 0, count: length / MemoryLayout<Float>.stride)
CMBlockBufferCopyDataBytes(blockBuffer, atOffset: 0, dataLength: length, destination: &data)
samples.append(contentsOf: data)
}
return samples
}
// ── Pixel buffer → float array ────────────────────
public static func pixelBufferToFloats(_ pixelBuffer: CVPixelBuffer) -> [Float] {
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return [] }
var floats = [Float](repeating: 0, count: width * height * 3) // RGB
let pixelBuffer = baseAddress.assumingMemoryBound(to: UInt8.self)
for y in 0..<height {
for x in 0..<width {
let offset = y * bytesPerRow + x * 4
let b = Float(pixelBuffer[offset]) / 255.0
let g = Float(pixelBuffer[offset + 1]) / 255.0
let r = Float(pixelBuffer[offset + 2]) / 255.0
let pixelOffset = (y * width + x) * 3
floats[pixelOffset] = r
floats[pixelOffset + 1] = g
floats[pixelOffset + 2] = b
}
}
return floats
}
/// Resize pixel buffer to target size using vImage
public static func resizePixelBuffer(
_ pixelBuffer: CVPixelBuffer,
targetWidth: Int,
targetHeight: Int
) -> CVPixelBuffer? {
let srcWidth = CVPixelBufferGetWidth(pixelBuffer)
let srcHeight = CVPixelBufferGetHeight(pixelBuffer)
var srcBuffer = vImage_Buffer()
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
srcBuffer.data = CVPixelBufferGetBaseAddress(pixelBuffer)
srcBuffer.width = vImagePixelCount(srcWidth)
srcBuffer.height = vImagePixelCount(srcHeight)
srcBuffer.rowBytes = CVPixelBufferGetBytesPerRow(pixelBuffer)
var destPixelBuffer: CVPixelBuffer?
let attrs: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferMetalCompatibilityKey as String: true,
]
CVPixelBufferCreate(
kCFAllocatorDefault,
targetWidth, targetHeight,
kCVPixelFormatType_32BGRA,
attrs as CFDictionary,
&destPixelBuffer
)
guard let dest = destPixelBuffer else {
CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
return nil
}
CVPixelBufferLockBaseAddress(dest, [])
var destBuffer = vImage_Buffer()
destBuffer.data = CVPixelBufferGetBaseAddress(dest)
destBuffer.width = vImagePixelCount(targetWidth)
destBuffer.height = vImagePixelCount(targetHeight)
destBuffer.rowBytes = CVPixelBufferGetBytesPerRow(dest)
let scale = vImageScale_ARGB8888(&srcBuffer, &destBuffer, nil, vImage_Flags(kvImageNoFlags))
CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
CVPixelBufferUnlockBaseAddress(dest, [])
return scale == kvImageNoError ? dest : nil
}
/// Frame to patch embeddings (simple 16×16 grid)
public static func frameToPatchEmbeddings(
_ pixelBuffer: CVPixelBuffer,
patchSize: Int = 16
) -> (embeddings: [Float], numPatches: Int, patchDim: Int) {
let rgb = pixelBufferToFloats(pixelBuffer)
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
let numPatchesH = height / patchSize
let numPatchesW = width / patchSize
let numPatches = numPatchesH * numPatchesW
let patchDim = patchSize * patchSize * 3
var embeddings = [Float](repeating: 0, count: numPatches * patchDim)
for ph in 0..<numPatchesH {
for pw in 0..<numPatchesW {
let patchIdx = ph * numPatchesW + pw
for py in 0..<patchSize {
for px in 0..<patchSize {
let srcY = ph * patchSize + py
let srcX = pw * patchSize + px
let srcIdx = (srcY * width + srcX) * 3
let dstIdx = (patchIdx * patchDim) + (py * patchSize + px) * 3
if srcIdx + 2 < rgb.count, dstIdx + 2 < embeddings.count {
embeddings[dstIdx] = rgb[srcIdx]
embeddings[dstIdx + 1] = rgb[srcIdx + 1]
embeddings[dstIdx + 2] = rgb[srcIdx + 2]
}
}
}
}
}
return (embeddings, numPatches, patchDim)
}
}