Skip to content

Commit 060c697

Browse files
committed
fix: video thumbnails — generate locally, blurhash fallback, settings toggle
- Generate thumbnails using AVAssetImageGenerator when the server provides none and the user enables 'Generate video thumbnails' in settings (off by default, downloads the video) - Decode blurhash from event metadata as a lightweight placeholder when no thumbnail or generated image is available (no download needed) - Refactor video download into shared downloadVideo() helper, reused by both playback and thumbnail generation - Add BlurHashDecoder.swift (MIT, based on woltapp/blurhash) - Add settings toggle in Appearance preferences
1 parent 4eb7acb commit 060c697

3 files changed

Lines changed: 197 additions & 18 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// BlurHash decoder for macOS
2+
// Based on https://github.com/woltapp/blurhash (MIT licence)
3+
4+
import AppKit
5+
6+
extension NSImage {
7+
/// Decodes a blurhash string into an NSImage of the given size.
8+
static func fromBlurHash(_ blurHash: String, size: CGSize, punch: Float = 1) -> NSImage? {
9+
guard blurHash.count >= 6 else { return nil }
10+
let chars = Array(blurHash)
11+
12+
guard let sizeFlag = decode83(chars[0]) else { return nil }
13+
let numY = (sizeFlag / 9) + 1
14+
let numX = (sizeFlag % 9) + 1
15+
guard blurHash.count == 4 + 2 * numX * numY else { return nil }
16+
17+
guard let quantisedMaximumValue = decode83(chars[1]) else { return nil }
18+
let maximumValue = Float(quantisedMaximumValue + 1) / 166
19+
20+
var colours = [(Float, Float, Float)]()
21+
for i in 0..<(numX * numY) {
22+
if i == 0 {
23+
guard let value = decode83(Array(chars[2..<6])) else { return nil }
24+
colours.append(decodeDC(value))
25+
} else {
26+
let start = 4 + i * 2
27+
guard let value = decode83(Array(chars[start..<start + 2])) else { return nil }
28+
colours.append(decodeAC(value, maximumValue: maximumValue * punch))
29+
}
30+
}
31+
32+
let width = Int(size.width)
33+
let height = Int(size.height)
34+
var pixels = [UInt8](repeating: 0, count: width * height * 4)
35+
36+
for y in 0..<height {
37+
for x in 0..<width {
38+
var r: Float = 0, g: Float = 0, b: Float = 0
39+
for j in 0..<numY {
40+
for i in 0..<numX {
41+
let basis = cos(Float.pi * Float(i) * Float(x) / Float(width))
42+
* cos(Float.pi * Float(j) * Float(y) / Float(height))
43+
let colour = colours[i + j * numX]
44+
r += colour.0 * basis
45+
g += colour.1 * basis
46+
b += colour.2 * basis
47+
}
48+
}
49+
let offset = 4 * (x + y * width)
50+
pixels[offset] = UInt8(linearTosRGB(r))
51+
pixels[offset + 1] = UInt8(linearTosRGB(g))
52+
pixels[offset + 2] = UInt8(linearTosRGB(b))
53+
pixels[offset + 3] = 255
54+
}
55+
}
56+
57+
guard let provider = CGDataProvider(data: Data(pixels) as CFData),
58+
let cgImage = CGImage(
59+
width: width, height: height,
60+
bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: width * 4,
61+
space: CGColorSpace(name: CGColorSpace.sRGB)!,
62+
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
63+
provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent
64+
) else { return nil }
65+
66+
return NSImage(cgImage: cgImage, size: size)
67+
}
68+
}
69+
70+
private let decodeCharacters: [Character: Int] = {
71+
let chars = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-./:;=?@[]^_{|}~")
72+
return Dictionary(uniqueKeysWithValues: chars.enumerated().map { ($1, $0) })
73+
}()
74+
75+
private func decode83(_ chars: [Character]) -> Int? {
76+
var value = 0
77+
for c in chars {
78+
guard let digit = decodeCharacters[c] else { return nil }
79+
value = value * 83 + digit
80+
}
81+
return value
82+
}
83+
84+
private func decode83(_ char: Character) -> Int? {
85+
decodeCharacters[char]
86+
}
87+
88+
private func decodeDC(_ value: Int) -> (Float, Float, Float) {
89+
(sRGBToLinear(value >> 16), sRGBToLinear((value >> 8) & 255), sRGBToLinear(value & 255))
90+
}
91+
92+
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
93+
let qR = value / (19 * 19)
94+
let qG = (value / 19) % 19
95+
let qB = value % 19
96+
return (
97+
signPow((Float(qR) - 9) / 9, 2) * maximumValue,
98+
signPow((Float(qG) - 9) / 9, 2) * maximumValue,
99+
signPow((Float(qB) - 9) / 9, 2) * maximumValue
100+
)
101+
}
102+
103+
private func signPow(_ value: Float, _ exp: Float) -> Float {
104+
copysign(pow(abs(value), exp), value)
105+
}
106+
107+
private func linearTosRGB(_ value: Float) -> Int {
108+
let v = max(0, min(1, value))
109+
return v <= 0.0031308
110+
? Int(v * 12.92 * 255 + 0.5)
111+
: Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5)
112+
}
113+
114+
private func sRGBToLinear(_ value: Int) -> Float {
115+
let v = Float(value) / 255
116+
return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
117+
}

Mactrix/Views/ChatView/MessageVideoView.swift

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ import SwiftUI
66

77
struct MessageVideoView: View {
88
@Environment(AppState.self) private var appState
9+
@AppStorage("generateVideoThumbnails") var generateVideoThumbnails: Bool = false
910
let content: VideoMessageContent
1011

1112
@State private var fileHandle: MediaFileHandle?
1213
@State private var video: AVPlayer?
14+
@State private var generatedThumbnail: Image?
15+
16+
private enum VideoError: Error { case noClient }
1317

1418
var aspectRatio: CGFloat? {
1519
guard let info = content.info,
@@ -24,37 +28,87 @@ struct MessageVideoView: View {
2428
return min(CGFloat(height), 300)
2529
}
2630

27-
func loadVideo() async {
28-
guard let client = appState.matrixClient?.client else { return }
29-
30-
do {
31-
let handle = try await client.getMediaFile(
32-
mediaSource: content.source,
33-
filename: content.filename,
34-
mimeType: content.info?.mimetype ?? "",
35-
useCache: true,
36-
tempDir: NSTemporaryDirectory()
37-
)
31+
private func downloadVideo() async throws -> URL {
32+
guard let client = appState.matrixClient?.client else {
33+
throw VideoError.noClient
34+
}
3835

39-
fileHandle = handle
40-
let path = try handle.path()
41-
let url = URL(filePath: path, directoryHint: .notDirectory)
36+
let handle = try await client.getMediaFile(
37+
mediaSource: content.source,
38+
filename: content.filename,
39+
mimeType: content.info?.mimetype ?? "",
40+
useCache: true,
41+
tempDir: NSTemporaryDirectory()
42+
)
43+
fileHandle = handle
44+
let path = try handle.path()
45+
return URL(filePath: path, directoryHint: .notDirectory)
46+
}
4247

48+
func loadVideo(autoplay: Bool = true) async {
49+
do {
50+
let url: URL
51+
if let handle = fileHandle {
52+
url = URL(filePath: try handle.path(), directoryHint: .notDirectory)
53+
} else {
54+
url = try await downloadVideo()
55+
}
4356
video = AVPlayer(url: url)
44-
video?.play()
57+
if autoplay { video?.play() }
4558
} catch {
4659
Logger.viewCycle.error("Failed to load video: \(error)")
4760
}
4861
}
4962

63+
private func generateThumbnail() async {
64+
let cacheKey = NSString(string: "thumb:" + content.source.url())
65+
if let cached = MatrixClient.imageCache.object(forKey: cacheKey) {
66+
generatedThumbnail = Image(nsImage: cached)
67+
return
68+
}
69+
70+
do {
71+
let url = try await downloadVideo()
72+
73+
let asset = AVURLAsset(url: url)
74+
let generator = AVAssetImageGenerator(asset: asset)
75+
generator.appliesPreferredTrackTransform = true
76+
generator.maximumSize = CGSize(width: 600, height: 600)
77+
78+
let cgImage = try await generator.image(at: .zero).image
79+
let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
80+
MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey)
81+
generatedThumbnail = Image(nsImage: nsImage)
82+
} catch {
83+
Logger.viewCycle.error("Failed to generate video thumbnail: \(error)")
84+
}
85+
}
86+
87+
@ViewBuilder
88+
var thumbnailView: some View {
89+
if let thumbnailSource = content.info?.thumbnailSource {
90+
MatrixImageView(mediaSource: thumbnailSource, mimeType: content.info?.thumbnailInfo?.mimetype)
91+
} else if let generatedThumbnail {
92+
generatedThumbnail.resizable().scaledToFit()
93+
} else if let blurhash = content.info?.blurhash,
94+
let info = content.info,
95+
let w = info.width, w > 0,
96+
let h = info.height, h > 0,
97+
let nsImage = NSImage.fromBlurHash(blurhash, size: CGSize(width: 32, height: Int(32 * CGFloat(h) / CGFloat(w)))) {
98+
Image(nsImage: nsImage).resizable().scaledToFit()
99+
} else {
100+
Rectangle().fill(Color.gray.opacity(0.3))
101+
}
102+
}
103+
50104
var body: some View {
51105
VStack {
52106
if let video {
53107
TimelineVideoPlayer(videoPlayer: video)
54108
.cornerRadius(6)
55109
} else {
56110
Button(action: { Task { await loadVideo() } }) {
57-
MatrixImageView(mediaSource: content.info?.thumbnailSource, mimeType: content.info?.thumbnailInfo?.mimetype)
111+
thumbnailView
58112
.overlay {
59113
Image(systemName: "play.fill")
60114
.resizable()
@@ -73,6 +127,11 @@ struct MessageVideoView: View {
73127
}
74128
.aspectRatio(aspectRatio, contentMode: .fit)
75129
.frame(maxHeight: maxHeight)
76-
.frame(minHeight: content.info?.thumbnailSource == nil ? maxHeight : nil)
130+
.frame(minHeight: content.info?.thumbnailSource == nil && generatedThumbnail == nil ? maxHeight : nil)
131+
.task(id: content.source.url()) {
132+
if content.info?.thumbnailSource == nil && generateVideoThumbnails {
133+
await generateThumbnail()
134+
}
135+
}
77136
}
78137
}

Mactrix/Views/Settings/AppearanceSettingsView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import SwiftUI
22

33
struct AppearanceSettingsView: View {
44
@AppStorage("fontSize") var fontSize: Int = 13
5-
5+
@AppStorage("generateVideoThumbnails") var generateVideoThumbnails: Bool = false
6+
67
var body: some View {
78
Form {
89
Picker("Font size", selection: $fontSize) {
@@ -11,6 +12,8 @@ struct AppearanceSettingsView: View {
1112
.tag($0)
1213
}
1314
}
15+
Toggle("Generate video thumbnails", isOn: $generateVideoThumbnails)
16+
.help("Downloads videos to generate thumbnails when the server doesn't provide one. Also pre-caches videos for instant playback.")
1417
}
1518
}
1619
}

0 commit comments

Comments
 (0)