Skip to content

Commit

Permalink
Video and audio info collection
Browse files Browse the repository at this point in the history
  • Loading branch information
starkdmi committed Dec 4, 2023
1 parent 3d78ced commit b251761
Show file tree
Hide file tree
Showing 11 changed files with 223 additions and 108 deletions.
3 changes: 2 additions & 1 deletion Example/MediaToolSwiftExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,8 @@ struct ContentView: View {
case .progress(let progress):
// print("Progress: \(progress.fractionCompleted)")
self.progress = progress.fractionCompleted
case .completed(let url):
case .completed(let info):
let url = info.url
print("Done: \(url.absoluteString)")
self.progress = nil
self.outputURL = url
Expand Down
16 changes: 4 additions & 12 deletions Files/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,6 @@ __Base__
- __Video thumbnails overwrite option__
- __Audio only__ - remove video and metadata tracks

__Info__ - function to extract info from a video file:
```
resolution, rotation, filesize, duration, frame rate,
video - codec, bitrate, hasAlpha, isHDR, color primaries, pixel format,
audio - codec, bitrate, sample rate, waveform,
metadata - date, location, where from, original filename with raw extended attributes dictionary + asset.getMetadata() list + track.metadata
```

__Codecs__
```
VP9, AV1
Expand Down Expand Up @@ -55,13 +47,13 @@ __Base__
- __AudioToolbox__ - Instead of using `VideoToolBox` (AVAssetReader/AVAssetWriter) try `AudioToolBox` framework
- __MP3 support__ - Add `MP3` support to the `plus` branch via [libmp3lame.a](https://github.com/maysamsh/Swift-MP3Converter) (slow, 2MB of size)

| Speed adjustment | [Reverse](https://www.limit-point.com/blog/2022/reverse-audio/) | Waveform | Custom Chunk Processor | Info |
| :---: | :---: | :---: | :---: | :---: |
| 🚧 | 🚧 ||| 🚧 |
| Speed adjustment | [Reverse](https://www.limit-point.com/blog/2022/reverse-audio/) | Waveform | Custom Chunk Processor |
| :---: | :---: | :---: | :---: |
| 🚧 | 🚧 |||

__Info__
```
format, filesize, bitrate, duration, sample rate, waveform
filesize, sample rate, waveform
```

## Image
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ __Video compressor focused on:__
__[Features](Files/VIDEO.md)__
| Convert | Resize | Crop | Cut | Rotate, Flip, Mirror | Image Processing[\*](Tests/VideoTests.swift#:~:text=testImageProcessing) | FPS | Thumbnail | Info |
| :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| ✔️ | ✔️ | ✔️ | ⭐️ | ⭐️ | ✔️ | ✔️ | ✔️ | 🚧 |
| ✔️ | ✔️ | ✔️ | ⭐️ | ⭐️ | ✔️ | ✔️ | ✔️ | ✔️ |

⭐️ - _do not require re-encoding (lossless)_

Expand Down Expand Up @@ -178,7 +178,7 @@ __Audio converter focused on:__
__[Features](Files/AUDIO.md)__
| Convert | Cut | Info |
| :---: | :---: | :---: |
| ✔️ | ⭐️ | 🚧 |
| ✔️ | ⭐️ | ✔️ |

⭐️ - _do not require re-encoding (lossless)_

Expand Down
15 changes: 13 additions & 2 deletions Sources/Audio.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,21 +282,32 @@ public struct AudioTool {
reader.cancelReading()
writer.finishWriting(completionHandler: {
// Extended file metadata
var extendedInfo: ExtendedFileInfo?
if copyExtendedFileMetadata {
FileExtendedAttributes.copyExtendedMetadata(
let data = FileExtendedAttributes.copyExtendedMetadata(
from: source.path,
to: destination.path,
customAttributes: [:]
)
extendedInfo = FileExtendedAttributes.extractExtendedFileInfo(from: data)
}

// Audio info
let audioInfo = AudioInfo(
url: writer.outputURL,
duration: duration.seconds,
codec: audioVariables.codec,
bitrate: audioVariables.bitrate,
extendedInfo: extendedInfo
)

if deleteSourceFile, source.path != destination.path {
// Delete input audio file
try? FileManager.default.removeItem(at: source)
}

// Finish
callback(.completed(writer.outputURL))
callback(.completed(audioInfo))
})
} else { // Cancelled
// Wait for sample in progress to complete, 0.5 sec is more than enough
Expand Down
136 changes: 89 additions & 47 deletions Sources/Classes/FileExtendedAttributes.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
// import CoreLocation
import CoreLocation

#if canImport(ObjCExceptionCatcher)
import ObjCExceptionCatcher
Expand All @@ -20,21 +20,27 @@ internal class FileExtendedAttributes {
/// - source: Original file path string
/// - destination: Destination file path string
/// - fileType: Video file container type
static func setExtendedMetadata(source: URL, destination: URL, copy: Bool, fileType: VideoFileType) {
static func setExtendedMetadata(
source: URL,
destination: URL,
copy: Bool, fileType: VideoFileType
) -> [String: Data] {
// Apple file system metadata
let attributes: [String: Any] = [
let attributes: [String: Data] = [
Self.assetTypeKey: "video/\(fileType == .mp4 ? "mp4" : "quicktime")".data(using: .utf8)!
// Self.durationKey: String(format: "%.3f", durationInSeconds).data(using: .utf8)! // String
// Self.durationKey: Data(bytes: &durationInSeconds, count: MemoryLayout<Double>.size) // Bytes
]

if copy {
Self.copyExtendedMetadata(
return Self.copyExtendedMetadata(
from: source.path,
to: destination.path,
customAttributes: attributes
)
} else {
Self.setExtendedAttributes(attributes, ofItemAtPath: destination.path)
return attributes
}
}

Expand All @@ -43,66 +49,33 @@ internal class FileExtendedAttributes {
/// - source: Original file path string
/// - destination: Destination file path string
/// - customAttributes: List of extended attributes to be added to file, use with caution
static func copyExtendedMetadata(from source: String, to destination: String, customAttributes: [String: Any] = [:]) {
static func copyExtendedMetadata(
from source: String,
to destination: String,
customAttributes: [String: Data] = [:]
) -> [String: Data] {
// Read source file metadata
// Can also be read by `xattr -l file.mp4`
guard let dictionary = try? FileManager.default.attributesOfItem(atPath: source) else {
// print("No extended attributes found")
return
return [:]
}
let attributes = NSDictionary(dictionary: dictionary)

var data: [String: Any] = [:] // selected key-values
var data: [String: Data] = [:] // selected key-values
if let extendedAttributes = attributes[extendedAttributesKey] as? [String: Any] {
// Device/User/URL - com.apple.metadata:kMDItemWhereFroms: bplist00?^Dmitry SXiPhone 13
// Where from
if let whereFromData = extendedAttributes[whereFromsKey] as? Data {
/*if let values = try? PropertyListSerialization.propertyList(from: whereFromData, options: [], format: nil) as? [String] {
print("Where from: \(values)") // ["Dmitry S", "iPhone 13"]
}*/
data[whereFromsKey] = whereFromData
}

// Location - com.apple.assetsd.customLocation: g??j+FB@?;Nё=@
// Location
if let customLocationData = extendedAttributes[customLocationKey] as? Data {
/*// Coordinates are rounded and stored in first 16 of 64 bytes
// Real coordinates: 36.54819444444444, 29.11145833333333
let latitude = Double(customLocationData.withUnsafeBytes { $0.load(as: Double.self) }) // 36.5482
let longitude = Double(customLocationData.advanced(by: 8).withUnsafeBytes { $0.load(as: Double.self) }) // 29.1116
print("Location: \(latitude), \(longitude)")

// INFO: Bytes from 16-24 are probably altitude (com.apple.quicktime.location.altitude)
// but both 000.555 and 031.058 altitude values weren't stored at all (!)
// print(Double(customLocationData.advanced(by: 16).withUnsafeBytes { $0.load(as: Double.self) })) // 0.0

// Horizontal Accuracy - 4.766546
let horizontalAccuracy = Double(customLocationData.advanced(by: 24).withUnsafeBytes { $0.load(as: Double.self) })
print("Horizontal Accuracy: \(horizontalAccuracy) meters")

// INFO: Bytes from 32-56 - unknown values stored
// - verticalAccuracy (com.apple.quicktime.location.accuracy.vertical) - probably 32-40 range
// - course (com.apple.quicktime.location.speed)
// - speed (com.apple.quicktime.location.course)
print(Double(customLocationData.advanced(by: 32).withUnsafeBytes { $0.load(as: Double.self) })) // 0.0
print(Double(customLocationData.advanced(by: 40).withUnsafeBytes { $0.load(as: Double.self) })) // 0.0
print(Double(customLocationData.advanced(by: 48).withUnsafeBytes { $0.load(as: Double.self) })) // 0.0

// Timestamp - 688827791.0 (2022-10-30T13:03:11+0300)
// Timestamp stored in seconds since reference date - January 1, 2001
let timestamp = Double(customLocationData.advanced(by: 56).withUnsafeBytes { $0.load(as: Double.self) })
let date = NSDate(timeIntervalSinceReferenceDate: timestamp)
print("Date: \(date)") // 2022-10-30 13:03:11 +0000 (time zone lost)

let bytes = [UInt8](customLocationData)
let hexString = bytes.map { String(format: "%02x", $0) }.joined()
print("HEX: \(hexString)")*/
data[customLocationKey] = customLocationData
}

// Original file name - com.apple.assetsd.originalFilename: IMG_3754.MOV
// Filename
if let originalFilenameData = extendedAttributes[originalFilenameKey] as? Data {
/*if let originalFilename = String(data: originalFilenameData, encoding: .utf8) {
print("Original Filename: \(originalFilename)")
}*/
data[originalFilenameKey] = originalFilenameData
}

Expand All @@ -118,6 +91,8 @@ internal class FileExtendedAttributes {
setExtendedAttributes(data, ofItemAtPath: destination)
}
} // else { print("No extended attributes found") }

return data
}

/// Safely set extended attributes
Expand All @@ -134,4 +109,71 @@ internal class FileExtendedAttributes {
}
} catch { }
}

/// Decode file extended media keys from `Data` objects
static func extractExtendedFileInfo(from data: [String: Data]) -> ExtendedFileInfo {
var location: CLLocation?
var whereFrom: [String]?
var originalFilename: String?

for (key, value) in data {
switch key {
case FileExtendedAttributes.customLocationKey:
// Location - com.apple.assetsd.customLocation: g??j+FB@?;Nё=@

// Coordinates are rounded and stored in first 16 of 64 bytes
// Real coordinates: 36.54819444444444, 29.11145833333333
let latitude = Double(value.withUnsafeBytes { $0.load(as: Double.self) }) // 36.5482
let longitude = Double(value.advanced(by: 8).withUnsafeBytes { $0.load(as: Double.self) }) // 29.1116
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
// print("Location: \(latitude), \(longitude)")

// INFO: Bytes from 16-24 are probably altitude (com.apple.quicktime.location.altitude)
// but both 000.555 and 031.058 altitude values weren't stored at all (!)
// print(Double(customLocationData.advanced(by: 16).withUnsafeBytes { $0.load(as: Double.self) })) // 0.0

// Horizontal Accuracy - 4.766546
let horizontalAccuracy = Double(value.advanced(by: 24).withUnsafeBytes { $0.load(as: Double.self) })
// print("Horizontal Accuracy: \(horizontalAccuracy) meters")

// INFO: Bytes from 32-56 - unknown values stored
// - verticalAccuracy (com.apple.quicktime.location.accuracy.vertical) - probably 32-40 range
// - course (com.apple.quicktime.location.speed)
// - speed (com.apple.quicktime.location.course)
// print(Double(value.advanced(by: 32).withUnsafeBytes { $0.load(as: Double.self) })) // 0.0
// print(Double(value.advanced(by: 40).withUnsafeBytes { $0.load(as: Double.self) })) // 0.0
// print(Double(value.advanced(by: 48).withUnsafeBytes { $0.load(as: Double.self) })) // 0.0

// Timestamp - 688827791.0 (2022-10-30T13:03:11+0300)
// Timestamp stored in seconds since reference date - January 1, 2001
let timestamp = Double(value.advanced(by: 56).withUnsafeBytes { $0.load(as: Double.self) })
let date = NSDate(timeIntervalSinceReferenceDate: timestamp) as Date
// print("Date: \(date)") // 2022-10-30 13:03:11 +0000 (time zone lost)

// let bytes = [UInt8](value)
// let hexString = bytes.map { String(format: "%02x", $0) }.joined()
// print("HEX: \(hexString)")

location = CLLocation(coordinate: coordinate, altitude: .zero, horizontalAccuracy: horizontalAccuracy, verticalAccuracy: .zero, timestamp: date)
case FileExtendedAttributes.whereFromsKey:
// Device/User/URL - com.apple.metadata:kMDItemWhereFroms: bplist00?^Dmitry SXiPhone 13
if let values = try? PropertyListSerialization.propertyList(from: value, options: [], format: nil) as? [String] {
whereFrom = values // ["Dmitry S", "iPhone 13"]
}
case FileExtendedAttributes.originalFilenameKey:
// Original file name - com.apple.assetsd.originalFilename: IMG_3754.MOV
if let filename = String(data: value, encoding: .utf8) {
originalFilename = filename
}
default:
break
}
}

return ExtendedFileInfo(
location: location,
whereFrom: whereFrom,
originalFilename: originalFilename
)
}
}
34 changes: 34 additions & 0 deletions Sources/Types/Audio/AudioInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import AVFoundation

/// Audio information
public struct AudioInfo: MediaInfo {
/// Public initializer
public init(
url: URL,
duration: Double,
codec: CompressionAudioCodec?,
bitrate: Int?,
extendedInfo: ExtendedFileInfo?
) {
self.url = url
self.duration = duration
self.codec = codec
self.bitrate = bitrate
self.extendedInfo = extendedInfo
}

/// Audio file path
public let url: URL

/// Audio duration, in seconds
public let duration: Double

/// Audio codec
public let codec: CompressionAudioCodec?

/// Audio bitrate
public let bitrate: Int?

/// Extended media information
public let extendedInfo: ExtendedFileInfo?
}
35 changes: 35 additions & 0 deletions Sources/Types/Shared/ExtendedFileInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import AVFoundation
import CoreLocation

/// Additional media information
public struct ExtendedFileInfo {
/// Public initializer
public init(
// date: Date?,
location: CLLocation?,
whereFrom: [String]?,
originalFilename: String?
// filesize: Int64?
) {
// self.date = date
self.location = location
self.whereFrom = whereFrom
self.originalFilename = originalFilename
// self.filesize = filesize
}

// Original date
// public let date: Date?

/// Location
public let location: CLLocation?

/// Where from
public let whereFrom: [String]?

/// Original file name
public let originalFilename: String?

// File size
// public let filesize: Int64?
}
10 changes: 10 additions & 0 deletions Sources/Types/Shared/MediaInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

/// Media info protocol type
public protocol MediaInfo {
/// Media file path
var url: URL { get }

/// Extended media information
var extendedInfo: ExtendedFileInfo? { get }
}
4 changes: 2 additions & 2 deletions Sources/Types/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public enum CompressionState: Equatable {
case progress(Progress)

/// Compression finished with success, contains the destination file url
case completed(URL)
case completed(MediaInfo)

/// Compression failed with error
case failed(Error)
Expand All @@ -25,7 +25,7 @@ public enum CompressionState: Equatable {
case (.progress(let lhsValue), .progress(let rhsValue)):
return lhsValue == rhsValue
case (.completed(let lhsValue), .completed(let rhsValue)):
return lhsValue == rhsValue
return lhsValue.url == rhsValue.url
case (.failed(let lhsValue), .failed(let rhsValue)):
return lhsValue.localizedDescription == rhsValue.localizedDescription
case (.cancelled, .cancelled):
Expand Down
Loading

0 comments on commit b251761

Please sign in to comment.