Skip to content
Merged
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]

### Added

- `record-video --gif-markers` (all three surfaces): bracket a GIF with START/END marker cards (~1 s each) so the forever-looping clip has a visible boundary. Opt-in — the default output remains a faithful capture of the screen. 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, `--gif-markers`
can bracket the clip with START/END marker cards (~1 s each) so the
boundary is visible.

### 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(help: "Bracket a GIF with START/END marker frames (opt-in; ignored for mp4).")
public var gifMarkers: Bool = false

@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 = false
) 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(help: "Bracket a GIF with START/END marker frames (opt-in; ignored for mp4).")
var gifMarkers: Bool = false

@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` (opt-in), 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 = false) 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 = false) 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 = false) {
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 = false) 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(help: "Bracket a GIF with START/END marker frames (opt-in; ignored for mp4).")
public var gifMarkers: Bool = false

@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
54 changes: 54 additions & 0 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) // opt-in, default off

let inferred = Options(format: nil, output: "demo.gif", fps: nil, scale: nil)
#expect(inferred.format == .gif)
Expand Down Expand Up @@ -238,6 +239,59 @@ struct GIFTranscoderTests {
#expect(CGImageSourceGetCount(try #require(CGImageSourceCreateWithURL(gifURL as CFURL, nil))) == written)
}

@Test("Opt-in markers bracket the GIF with START/END cards")
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, markers: true)

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
2 changes: 1 addition & 1 deletion skills/sim-use/references/cheatsheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ sim-use touch -x 150 -y 250 --down --up --delay 1.0 # long press
sim-use screenshot --output shot.png
sim-use record-video --output recording.mp4 # H.264, 30 fps default; Ctrl+C to stop
sim-use record-video --output smooth.mp4 --fps 60 # iOS: constant rate up to 60 fps (Android ignores --fps, native rate)
sim-use record-video --output demo.gif # animated GIF (inferred from extension, or --format gif); 10 fps + 0.5 scale defaults; transcoded after Ctrl+C
sim-use record-video --output demo.gif # animated GIF (inferred from extension, or --format gif); 10 fps + 0.5 scale defaults; add --gif-markers for START/END boundary cards; transcoded after Ctrl+C
sim-use stream-video --fps 10 --format mjpeg > out.mjpeg # live JPEG stream (both platforms)
sim-use stream-video --format h264 | ffplay -f h264 - # Android only: native H.264 passthrough (VFR)
```
Expand Down
Loading