Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion Sources/SimUseVideo/VideoCommandSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import Foundation
import AVFoundation
import ImageIO
import UniformTypeIdentifiers
import os
import SimUseCore

Expand Down Expand Up @@ -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))
Expand Down
7 changes: 4 additions & 3 deletions Tests/AndroidStreamVideoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}

Expand Down
51 changes: 51 additions & 0 deletions Tests/MJPEGTestSupport.swift
Original file line number Diff line number Diff line change
@@ -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..<stream.endIndex),
"MJPEG stream is missing its first frame boundary"
)
let partHeaderStart = partBoundary.upperBound
let partHeader = try #require(
stream.range(of: headerTerminator, in: partHeaderStart..<stream.endIndex),
"MJPEG frame is missing its header terminator"
)
let headerText = String(decoding: stream[partHeaderStart..<partHeader.lowerBound], as: UTF8.self)

func headerValue(_ name: String) -> 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..<stream.endIndex),
"MJPEG stream is missing the boundary after its first frame"
)

return MJPEGTestFrame(
contentType: contentType,
contentLength: contentLength,
payload: Data(stream[payloadStart..<nextBoundary.lowerBound])
)
}
6 changes: 5 additions & 1 deletion Tests/StreamVideoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ struct StreamVideoTests {
#expect(!result.output.isEmpty, "Should have stderr messages")
#expect(result.output.contains("Starting screenshot-based video stream"))
#expect(result.output.contains("Format: mjpeg"))
let frame = try firstMJPEGFrame(in: result.data)
#expect(frame.contentType == "image/jpeg")
#expect(frame.contentLength == frame.payload.count)
#expect(frame.payload.starts(with: [0xFF, 0xD8]))
}

@Test("Stream video outputs raw JPEG data for ffmpeg format")
Expand Down Expand Up @@ -162,4 +166,4 @@ struct StreamVideoTests {
let acceptable: Set<Int32> = [0, 9, 15, 130, 137, 143]
return acceptable.contains(code)
}
}
}
20 changes: 14 additions & 6 deletions Tests/VideoFrameProcessingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down