Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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]

### Added

- `record-video` GIFs are bracketed with START/END marker cards (~1 s each) so the forever-looping clip has a visible boundary. On by default for `--format gif` on all three surfaces; opt out with `--no-gif-markers`. A failed card render degrades to a marker-less GIF instead of failing the transcode.

## [0.13.0] - 2026-08-06

### Added
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,9 @@ to wall-clock. If the transcode fails, the intermediate MP4 is preserved
and its path reported, so the footage is never lost. GIF is meant for
short clips: the encoder holds every frame in memory until the file is
written, so for sessions beyond a few hundred frames prefer a lower
`--fps` or `--format mp4`.
`--fps` or `--format mp4`. Because the GIF loops forever, the clip is
bracketed with START/END marker cards (~1 s each) so the boundary is
visible; disable with `--no-gif-markers`.

### Accessibility inspection

Expand Down
11 changes: 8 additions & 3 deletions Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
@Option(help: "Output format: mp4, gif. Defaults to the --output extension when recognized, else mp4.")
public var format: RecordingFormat?

@Flag(inversion: .prefixedNo, help: "Bracket a GIF with START/END marker frames (default: enabled; ignored for mp4).")
public var gifMarkers: Bool = true

@Option(help: "Output file path. Defaults to sim-use-video-<timestamp>.<format> in the current directory.")
public var output: String?

Expand Down Expand Up @@ -81,7 +84,8 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
format: format,
fps: fps,
quality: quality,
scale: scale
scale: scale,
gifMarkers: gifMarkers
)
return ExecutionResult(path: outputURL.path)
}
Expand All @@ -108,14 +112,15 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand {
format: RecordingFormat?,
fps: Int?,
quality: Int,
scale: Double?
scale: Double?,
gifMarkers: Bool = true
) async throws -> URL {
let adb = Adb()
try assertAdbDeviceOnline(adb: adb, serial: serial)

// GIF is transcoded from a finished MP4 (see GIFTranscoder); the
// capture loop itself always writes H.264, to plan.recordTarget.
let plan = try RecordingOutputPlan(format: format, output: output, fps: fps, scale: scale)
let plan = try RecordingOutputPlan(format: format, output: output, fps: fps, scale: scale, gifMarkers: gifMarkers)
let options = plan.options
let recordTarget = plan.recordTarget
// Native screenrecord capture is variable-frame-rate either way;
Expand Down
7 changes: 6 additions & 1 deletion Sources/SimUse/Commands/RecordVideo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ struct RecordVideo: SimUseExecutableCommand {
@Option(help: "Output format: mp4, gif. Defaults to the --output extension when recognized, else mp4.")
var format: RecordingFormat?

@Flag(inversion: .prefixedNo, help: "Bracket a GIF with START/END marker frames (default: enabled; ignored for mp4).")
var gifMarkers: Bool = true

@Option(help: "Output file path. Defaults to sim-use-video-<timestamp>.<format> in the current directory.")
var output: String?

Expand Down Expand Up @@ -86,6 +89,7 @@ struct RecordVideo: SimUseExecutableCommand {
sub.quality = quality
sub.scale = scale
sub.format = format
sub.gifMarkers = gifMarkers
sub.output = output
sub.device = device
sub.json = json
Expand All @@ -99,7 +103,8 @@ struct RecordVideo: SimUseExecutableCommand {
format: format,
fps: fps,
quality: quality,
scale: scale
scale: scale,
gifMarkers: gifMarkers
)
return ExecutionResult(path: outputURL.path)
}
Expand Down
98 changes: 84 additions & 14 deletions Sources/SimUseVideo/GIFTranscoder.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
import Foundation
import AVFoundation
import CoreText
import ImageIO
import UniformTypeIdentifiers
import VideoToolbox
Expand Down Expand Up @@ -124,11 +125,18 @@ public enum GIFTranscoder {
return SamplingPlan(timestamps: kept, delays: delays)
}

/// How long each START/END marker card holds on screen. Long enough
/// to register before the loop restarts, short enough not to pad a
/// typical few-second repro clip.
static let markerDelay: Double = 1.0

/// Transcode `mp4URL` into an animated GIF at `gifURL`, sampling at
/// `fps` (capped at `maximumFPS`). Returns the number of frames
/// actually written.
/// `fps` (capped at `maximumFPS`). With `markers` (the default), the
/// clip is bracketed by START/END card frames so a forever-looping
/// GIF has a visible boundary. Returns the number of frames actually
/// written, marker cards included.
@discardableResult
public static func transcode(mp4URL: URL, to gifURL: URL, fps: Int) async throws -> Int {
public static func transcode(mp4URL: URL, to gifURL: URL, fps: Int, markers: Bool = true) async throws -> Int {
let asset = AVURLAsset(url: mp4URL)
guard let track = try await asset.loadTracks(withMediaType: .video).first else {
throw GIFTranscoderError.noVideoTrack
Expand All @@ -142,10 +150,24 @@ public enum GIFTranscoder {
FileHandle.standardError.write(Data("warning: GIF has \(plan.timestamps.count) frames; the encoder holds all of them in memory until the file is written — for long sessions prefer a lower --fps or --format mp4\n".utf8))
}

// Marker cards match the encoded frame size. A failed render
// falls back to a marker-less GIF rather than failing the
// transcode — the footage matters more than the chrome.
var cards: (start: CGImage, end: CGImage)?
if markers {
let size = try await track.load(.naturalSize)
if let start = Self.makeMarkerCard(width: Int(abs(size.width)), height: Int(abs(size.height)), label: "START"),
let end = Self.makeMarkerCard(width: Int(abs(size.width)), height: Int(abs(size.height)), label: "END") {
cards = (start, end)
} else {
FileHandle.standardError.write(Data("warning: could not render START/END marker frames; writing the GIF without them\n".utf8))
}
}

guard let destination = CGImageDestinationCreateWithURL(
gifURL as CFURL,
UTType.gif.identifier as CFString,
plan.timestamps.count,
plan.timestamps.count + (cards == nil ? 0 : 2),
nil
) else {
throw GIFTranscoderError.cannotCreateDestination(gifURL.path)
Expand All @@ -158,15 +180,21 @@ public enum GIFTranscoder {
]
CGImageDestinationSetProperties(destination, gifProperties as CFDictionary)

if let cards {
Self.append(cards.start, delay: Self.markerDelay, to: destination)
}
let appended = try Self.appendSampledFrames(asset: asset, track: track, plan: plan, to: destination)
guard appended > 0 else {
throw GIFTranscoderError.noFrames
}
if let cards {
Self.append(cards.end, delay: Self.markerDelay, to: destination)
}

guard CGImageDestinationFinalize(destination) else {
throw GIFTranscoderError.finalizeFailed
}
return appended
return appended + (cards == nil ? 0 : 2)
}

/// Convenience wrapper for the record-video post-step: transcodes and
Expand All @@ -175,10 +203,10 @@ public enum GIFTranscoder {
/// captured footage) while removing the partially written GIF, which
/// would otherwise read as a successful recording to any
/// does-the-file-exist check.
public static func transcodeRecording(tempMP4: URL, to gifURL: URL, fps: Int) async throws {
public static func transcodeRecording(tempMP4: URL, to gifURL: URL, fps: Int, markers: Bool = true) async throws {
FileHandle.standardError.write(Data("Transcoding to GIF...\n".utf8))
do {
let frames = try await transcode(mp4URL: tempMP4, to: gifURL, fps: fps)
let frames = try await transcode(mp4URL: tempMP4, to: gifURL, fps: fps, markers: markers)
try? FileManager.default.removeItem(at: tempMP4)
FileHandle.standardError.write(Data("GIF written (\(frames) frames)\n".utf8))
} catch {
Expand Down Expand Up @@ -257,13 +285,7 @@ public enum GIFTranscoder {
VTCreateCGImageFromCVPixelBuffer(pixelBuffer, options: nil, imageOut: &cgImage)
guard let cgImage else { continue }

let frameProperties: [CFString: Any] = [
kCGImagePropertyGIFDictionary: [
kCGImagePropertyGIFDelayTime: plan.delays[slot],
kCGImagePropertyGIFUnclampedDelayTime: plan.delays[slot]
]
]
CGImageDestinationAddImage(destination, cgImage, frameProperties as CFDictionary)
Self.append(cgImage, delay: plan.delays[slot], to: destination)
appended += 1
}
if reader.status == .failed {
Expand All @@ -272,4 +294,52 @@ public enum GIFTranscoder {
reader.cancelReading()
return appended
}

private static func append(_ image: CGImage, delay: Double, to destination: CGImageDestination) {
let frameProperties: [CFString: Any] = [
kCGImagePropertyGIFDictionary: [
kCGImagePropertyGIFDelayTime: delay,
kCGImagePropertyGIFUnclampedDelayTime: delay
]
]
CGImageDestinationAddImage(destination, image, frameProperties as CFDictionary)
}

/// A solid card with a centered white label, matching the encoded
/// frame size, used to bracket the clip. Internal for tests.
static func makeMarkerCard(width: Int, height: Int, label: String) -> CGImage? {
guard width > 0, height > 0,
let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
)
else { return nil }

context.setFillColor(CGColor(red: 0.08, green: 0.09, blue: 0.11, alpha: 1.0))
context.fill(CGRect(x: 0, y: 0, width: width, height: height))

// Size the label to the card so it reads at any recording scale.
let fontSize = CGFloat(min(width, height)) / 5.0
let font = CTFontCreateWithName("HelveticaNeue-Bold" as CFString, fontSize, nil)
let attributes: [CFString: Any] = [
kCTFontAttributeName: font,
kCTForegroundColorAttributeName: CGColor(red: 1, green: 1, blue: 1, alpha: 1)
]
guard let attributed = CFAttributedStringCreate(nil, label as CFString, attributes as CFDictionary) else {
return nil
}
let line = CTLineCreateWithAttributedString(attributed)
let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds)
context.textPosition = CGPoint(
x: (CGFloat(width) - bounds.width) / 2 - bounds.minX,
y: (CGFloat(height) - bounds.height) / 2 - bounds.minY
)
CTLineDraw(line, context)
return context.makeImage()
}
}
16 changes: 12 additions & 4 deletions Sources/SimUseVideo/RecordingFormat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,17 @@ public struct ResolvedRecordingOptions: Equatable, Sendable {
/// The rate GIF sampling runs at — always resolved, so the default
/// lives here and nowhere else.
public let gifSampleFPS: Int
/// Whether a GIF is bracketed with START/END marker frames.
public let gifMarkers: Bool

public init(format: RecordingFormat?, output: String?, fps: Int?, scale: Double?) {
public init(format: RecordingFormat?, output: String?, fps: Int?, scale: Double?, gifMarkers: Bool = true) {
let resolvedFormat = format ?? RecordingFormat.infer(fromOutput: output) ?? .mp4
let sampleFPS = fps ?? 10
self.format = resolvedFormat
self.gifSampleFPS = sampleFPS
self.fps = fps ?? (resolvedFormat == .gif ? sampleFPS : nil)
self.scale = scale ?? (resolvedFormat == .gif ? 0.5 : 1.0)
self.gifMarkers = gifMarkers
}

/// Where the H.264 capture should land: the final URL for mp4, an
Expand All @@ -69,8 +72,8 @@ public struct RecordingOutputPlan {
public let outputURL: URL
public let recordTarget: URL

public init(format: RecordingFormat?, output: String?, fps: Int?, scale: Double?) throws {
options = ResolvedRecordingOptions(format: format, output: output, fps: fps, scale: scale)
public init(format: RecordingFormat?, output: String?, fps: Int?, scale: Double?, gifMarkers: Bool = true) throws {
options = ResolvedRecordingOptions(format: format, output: output, fps: fps, scale: scale, gifMarkers: gifMarkers)
RecordingFormat.warnIfOverridingExtension(explicit: format, output: output)
outputURL = try VideoOutputFile.prepareOutputURL(output: output, fileExtension: options.format.rawValue)
recordTarget = options.recordTarget(for: outputURL)
Expand All @@ -84,6 +87,11 @@ public struct RecordingOutputPlan {
/// invalidated, so a stuck transcode stays interruptible.
public func finalizeRecording() async throws {
guard options.format == .gif else { return }
try await GIFTranscoder.transcodeRecording(tempMP4: recordTarget, to: outputURL, fps: options.gifSampleFPS)
try await GIFTranscoder.transcodeRecording(
tempMP4: recordTarget,
to: outputURL,
fps: options.gifSampleFPS,
markers: options.gifMarkers
)
}
}
5 changes: 4 additions & 1 deletion Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ public struct IOSSimRecordVideoCommand: SimUseExecutableCommand {
@Option(help: "Output format: mp4, gif. Defaults to the --output extension when recognized, else mp4.")
public var format: RecordingFormat?

@Flag(inversion: .prefixedNo, help: "Bracket a GIF with START/END marker frames (default: enabled; ignored for mp4).")
public var gifMarkers: Bool = true

@Option(help: "Output file path. Defaults to sim-use-video-<timestamp>.<format> in the current directory.")
public var output: String?

Expand Down Expand Up @@ -100,7 +103,7 @@ public struct IOSSimRecordVideoCommand: SimUseExecutableCommand {

// GIF is transcoded from a finished MP4 (see GIFTranscoder); the
// capture loop itself always writes H.264, to plan.recordTarget.
let plan = try RecordingOutputPlan(format: format, output: output, fps: fps, scale: scale)
let plan = try RecordingOutputPlan(format: format, output: output, fps: fps, scale: scale, gifMarkers: gifMarkers)
let options = plan.options
let recordTarget = plan.recordTarget
FileHandle.standardError.write(Data("Recording simulator \(targetSimulator.udid) to \(plan.outputURL.path)\n".utf8))
Expand Down
58 changes: 56 additions & 2 deletions Tests/GIFTranscoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ struct GIFTranscoderTests {
#expect(gif.fps == 10)
#expect(gif.gifSampleFPS == 10)
#expect(gif.scale == 0.5)
#expect(gif.gifMarkers) // default on

let inferred = Options(format: nil, output: "demo.gif", fps: nil, scale: nil)
#expect(inferred.format == .gif)
Expand Down Expand Up @@ -201,7 +202,7 @@ struct GIFTranscoderTests {
.appendingPathComponent("gif-transcoder-test-\(UUID().uuidString).gif")
defer { try? FileManager.default.removeItem(at: gifURL) }

let written = try await GIFTranscoder.transcode(mp4URL: mp4URL, to: gifURL, fps: 10)
let written = try await GIFTranscoder.transcode(mp4URL: mp4URL, to: gifURL, fps: 10, markers: false)

let source = try #require(CGImageSourceCreateWithURL(gifURL as CFURL, nil))
let type = try #require(CGImageSourceGetType(source) as String?)
Expand Down Expand Up @@ -233,11 +234,64 @@ struct GIFTranscoderTests {
.appendingPathComponent("gif-transcoder-test-\(UUID().uuidString).gif")
defer { try? FileManager.default.removeItem(at: gifURL) }

let written = try await GIFTranscoder.transcode(mp4URL: mp4URL, to: gifURL, fps: 10)
let written = try await GIFTranscoder.transcode(mp4URL: mp4URL, to: gifURL, fps: 10, markers: false)
#expect(written >= 8 && written <= 12)
#expect(CGImageSourceGetCount(try #require(CGImageSourceCreateWithURL(gifURL as CFURL, nil))) == written)
}

@Test("Markers bracket the GIF with START/END cards by default")
func transcodeAddsMarkerCards() async throws {
let mp4URL = try await makeSyntheticMP4(frameCount: 10, fps: 10)
defer { try? FileManager.default.removeItem(at: mp4URL) }
let gifURL = FileManager.default.temporaryDirectory
.appendingPathComponent("gif-transcoder-test-\(UUID().uuidString).gif")
defer { try? FileManager.default.removeItem(at: gifURL) }

let written = try await GIFTranscoder.transcode(mp4URL: mp4URL, to: gifURL, fps: 10)

let source = try #require(CGImageSourceCreateWithURL(gifURL as CFURL, nil))
let frameCount = CGImageSourceGetCount(source)
#expect(frameCount == written)
// 10 sampled content frames (±2 encoder variance) + 2 marker cards.
#expect(frameCount >= 10 && frameCount <= 12)

// Marker cards hold for the marker delay; content frames pace at 0.1 s.
for index in [0, frameCount - 1] {
let props = try #require(CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any])
let gif = try #require(props[kCGImagePropertyGIFDictionary] as? [CFString: Any])
let delay = try #require(gif[kCGImagePropertyGIFUnclampedDelayTime] as? Double)
#expect(abs(delay - GIFTranscoder.markerDelay) < 0.02)
}

// The cards match the content frame size.
let first = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil))
#expect(first.width == 64 && first.height == 64)
}

@Test("Marker card renders a light label on a dark background")
func markerCardRendering() throws {
let card = try #require(GIFTranscoder.makeMarkerCard(width: 120, height: 60, label: "START"))
#expect(card.width == 120 && card.height == 60)

var pixels = [UInt8](repeating: 0, count: 120 * 60 * 4)
let context = try #require(CGContext(
data: &pixels,
width: 120,
height: 60,
bitsPerComponent: 8,
bytesPerRow: 120 * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
))
context.draw(card, in: CGRect(x: 0, y: 0, width: 120, height: 60))

// Corner pixel is background (dark); the card must also contain
// bright text pixels somewhere.
#expect(pixels[0] < 60 && pixels[1] < 60 && pixels[2] < 60)
let hasBrightPixel = stride(from: 0, to: pixels.count, by: 4).contains { pixels[$0] > 200 }
#expect(hasBrightPixel)
}

@Test("A file with no video track throws noVideoTrack")
func transcodeRejectsNonVideo() async throws {
let bogusURL = FileManager.default.temporaryDirectory
Expand Down
Loading
Loading