diff --git a/CHANGELOG.md b/CHANGELOG.md index 1823b1e..d368a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Screenshot-backed `stream-video` formats now emit actual JPEG frames at the default quality and scale on both iOS and Android, instead of forwarding PNG screenshots unchanged; MJPEG frame payloads now match their `image/jpeg` MIME type. + ## [0.13.0] - 2026-08-06 ### Added diff --git a/Sources/SimUseVideo/VideoCommandSupport.swift b/Sources/SimUseVideo/VideoCommandSupport.swift index 00175be..4b9d1b6 100644 --- a/Sources/SimUseVideo/VideoCommandSupport.swift +++ b/Sources/SimUseVideo/VideoCommandSupport.swift @@ -2,6 +2,7 @@ import Foundation import AVFoundation import ImageIO +import UniformTypeIdentifiers import os import SimUseCore @@ -72,12 +73,20 @@ public struct VideoFrameUtilities { public static func processJPEGData(_ data: Data, scale: Double, quality: Int) async throws -> Data { if scale < 1.0 { return try await scaleJPEGData(data, scale: scale, quality: quality) - } else if quality != 80 { + } else if quality != 80 || !isJPEG(data) { return try await reencodeJPEGData(data, quality: quality) } return data } + private static func isJPEG(_ data: Data) -> Bool { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let typeIdentifier = CGImageSourceGetType(source) as String? else { + return false + } + return UTType(typeIdentifier)?.conforms(to: .jpeg) == true + } + public static func computeDimensions(for image: CGImage, scale: Double) -> (width: Int, height: Int) { let scaledWidth = max(2, Int(Double(image.width) * scale)) let scaledHeight = max(2, Int(Double(image.height) * scale)) diff --git a/Tests/AndroidStreamVideoTests.swift b/Tests/AndroidStreamVideoTests.swift index aa4bd4c..1646e41 100644 --- a/Tests/AndroidStreamVideoTests.swift +++ b/Tests/AndroidStreamVideoTests.swift @@ -20,9 +20,10 @@ struct AndroidStreamVideoTests { let result = try await streamForDuration(format: "mjpeg", duration: 4.0) #expect(isAcceptableStreamExitCode(result.exitCode), "Unexpected exit code: \(result.exitCode)") - let text = String(decoding: result.stdout.prefix(4096), as: UTF8.self) - #expect(text.contains("--mjpegstream")) - #expect(text.contains("Content-Type: image/jpeg")) + let frame = try firstMJPEGFrame(in: result.stdout) + #expect(frame.contentType == "image/jpeg") + #expect(frame.contentLength == frame.payload.count) + #expect(frame.payload.starts(with: [0xFF, 0xD8])) #expect(result.stderr.contains("Format: mjpeg")) } diff --git a/Tests/MJPEGTestSupport.swift b/Tests/MJPEGTestSupport.swift new file mode 100644 index 0000000..fd471ff --- /dev/null +++ b/Tests/MJPEGTestSupport.swift @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing + +struct MJPEGTestFrame { + let contentType: String + let contentLength: Int + let payload: Data +} + +func firstMJPEGFrame(in stream: Data) throws -> MJPEGTestFrame { + let headerTerminator = Data("\r\n\r\n".utf8) + let outerHeader = try #require( + stream.range(of: headerTerminator), + "MJPEG stream is missing its outer HTTP header" + ) + let boundary = Data("--mjpegstream\r\n".utf8) + let partBoundary = try #require( + stream.range(of: boundary, in: outerHeader.upperBound.. String? { + let prefix = "\(name):" + return headerText.components(separatedBy: "\r\n") + .first { $0.hasPrefix(prefix) } + .map { String($0.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) } + } + + let contentType = try #require(headerValue("Content-Type"), "MJPEG frame is missing Content-Type") + let lengthText = try #require(headerValue("Content-Length"), "MJPEG frame is missing Content-Length") + let contentLength = try #require(Int(lengthText), "MJPEG frame has an invalid Content-Length") + let payloadStart = partHeader.upperBound + let nextBoundaryPrefix = Data("\r\n--mjpegstream".utf8) + let nextBoundary = try #require( + stream.range(of: nextBoundaryPrefix, in: payloadStart.. = [0, 9, 15, 130, 137, 143] return acceptable.contains(code) } -} \ No newline at end of file +} diff --git a/Tests/VideoFrameProcessingTests.swift b/Tests/VideoFrameProcessingTests.swift index e11d06e..c6e4b6e 100644 --- a/Tests/VideoFrameProcessingTests.swift +++ b/Tests/VideoFrameProcessingTests.swift @@ -88,14 +88,22 @@ struct VideoFrameProcessingTests { #expect(tiny.height >= 2) } - @Test("processJPEGData passes data through untouched at default settings") - func processPassthrough() async throws { - // Pins the intentional fast path shared with iOS streaming: at - // scale 1.0 / quality 80 the frame is forwarded byte-for-byte - // (no decode/re-encode), whatever its container format. + @Test("processJPEGData encodes default PNG input as JPEG") + func processEncodesDefaultPNG() async throws { let png = try makePNG(width: 32, height: 32) let out = try await VideoFrameUtilities.processJPEGData(png, scale: 1.0, quality: 80) - #expect(out == png) + #expect(out.prefix(2) == Data([0xFF, 0xD8])) + let encoded = try #require(VideoFrameUtilities.makeCGImage(from: out)) + #expect(encoded.width == 32) + #expect(encoded.height == 32) + } + + @Test("processJPEGData passes default JPEG input through untouched") + func processPassesThroughDefaultJPEG() async throws { + let png = try makePNG(width: 32, height: 32) + let jpeg = try await VideoFrameUtilities.processJPEGData(png, scale: 1.0, quality: 90) + let out = try await VideoFrameUtilities.processJPEGData(jpeg, scale: 1.0, quality: 80) + #expect(out == jpeg) } @Test("non-default quality re-encodes to JPEG")