From 95708047e17ca5b58f23a3b3ab8bf9a8d692b780 Mon Sep 17 00:00:00 2001 From: wonseob Date: Thu, 20 Aug 2026 15:16:49 +0900 Subject: [PATCH 1/2] feat: bracket GIF exports with START/END marker cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A forever-looping GIF has no visible boundary — viewers can't tell where the flow starts and ends (team feedback on real repro clips). record-video --format gif now opens with a START card and closes with an END card (~1 s each), rendered at the encoded frame size with CoreText on a dark background. On by default on all three surfaces; opt out with --no-gif-markers. The flag resolves through ResolvedRecordingOptions/RecordingOutputPlan like the other GIF options, so the surfaces cannot drift. A failed card render degrades to a marker-less GIF instead of failing the transcode — the footage matters more than the chrome. Card dimensions come from the track's naturalSize, which matches the decoded frames for sim-use's own recordings (identity transform). Co-Authored-By: Claude Fable 5 Signed-off-by: wonseob --- CHANGELOG.md | 4 + README.md | 4 +- .../Verbs/AndroidRecordVideoCommand.swift | 11 ++- Sources/SimUse/Commands/RecordVideo.swift | 7 +- Sources/SimUseVideo/GIFTranscoder.swift | 98 ++++++++++++++++--- Sources/SimUseVideo/RecordingFormat.swift | 16 ++- .../Verbs/IOSSimRecordVideoCommand.swift | 5 +- Tests/GIFTranscoderTests.swift | 58 ++++++++++- skills/sim-use/references/cheatsheet.md | 2 +- 9 files changed, 178 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1823b1e5..41708b67 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] +### 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 diff --git a/README.md b/README.md index dc9779d1..00bf5708 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift b/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift index 67abf637..b490a2ef 100644 --- a/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift +++ b/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift @@ -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-. in the current directory.") public var output: String? @@ -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) } @@ -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; diff --git a/Sources/SimUse/Commands/RecordVideo.swift b/Sources/SimUse/Commands/RecordVideo.swift index f96fdcd8..a14ad306 100644 --- a/Sources/SimUse/Commands/RecordVideo.swift +++ b/Sources/SimUse/Commands/RecordVideo.swift @@ -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-. in the current directory.") var output: String? @@ -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 @@ -99,7 +103,8 @@ struct RecordVideo: SimUseExecutableCommand { format: format, fps: fps, quality: quality, - scale: scale + scale: scale, + gifMarkers: gifMarkers ) return ExecutionResult(path: outputURL.path) } diff --git a/Sources/SimUseVideo/GIFTranscoder.swift b/Sources/SimUseVideo/GIFTranscoder.swift index a0d9ccb7..b8b06f2e 100644 --- a/Sources/SimUseVideo/GIFTranscoder.swift +++ b/Sources/SimUseVideo/GIFTranscoder.swift @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import Foundation import AVFoundation +import CoreText import ImageIO import UniformTypeIdentifiers import VideoToolbox @@ -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 @@ -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) @@ -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 @@ -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 { @@ -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 { @@ -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() + } } diff --git a/Sources/SimUseVideo/RecordingFormat.swift b/Sources/SimUseVideo/RecordingFormat.swift index 39ec5d97..41853267 100644 --- a/Sources/SimUseVideo/RecordingFormat.swift +++ b/Sources/SimUseVideo/RecordingFormat.swift @@ -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 @@ -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) @@ -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 + ) } } diff --git a/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift b/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift index 77a14078..72416c09 100644 --- a/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift +++ b/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift @@ -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-. in the current directory.") public var output: String? @@ -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)) diff --git a/Tests/GIFTranscoderTests.swift b/Tests/GIFTranscoderTests.swift index 5d002c93..270bb03b 100644 --- a/Tests/GIFTranscoderTests.swift +++ b/Tests/GIFTranscoderTests.swift @@ -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) @@ -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?) @@ -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 diff --git a/skills/sim-use/references/cheatsheet.md b/skills/sim-use/references/cheatsheet.md index 93bb5e89..b3b6cc2c 100644 --- a/skills/sim-use/references/cheatsheet.md +++ b/skills/sim-use/references/cheatsheet.md @@ -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; START/END marker cards (--no-gif-markers to skip); 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) ``` From 414ca1b1556d78320a8799bd5e152a6f72516aa7 Mon Sep 17 00:00:00 2001 From: wonseob Date: Thu, 20 Aug 2026 17:16:11 +0900 Subject: [PATCH 2/2] fix: make GIF markers opt-in via --gif-markers Review follow-up (#100): a recording should be a faithful capture of the screen by default, and downstream consumers may assume the first frame is already the device screen. The markers are now opt-in (--gif-markers) on all three surfaces; the boolean leaves room to grow into a mode argument (overlay/color/image) later if wanted. Co-Authored-By: Claude Fable 5 Signed-off-by: wonseob --- CHANGELOG.md | 2 +- README.md | 6 +++--- .../Verbs/AndroidRecordVideoCommand.swift | 6 +++--- Sources/SimUse/Commands/RecordVideo.swift | 4 ++-- Sources/SimUseVideo/GIFTranscoder.swift | 10 +++++----- Sources/SimUseVideo/RecordingFormat.swift | 4 ++-- .../iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift | 4 ++-- Tests/GIFTranscoderTests.swift | 10 +++++----- skills/sim-use/references/cheatsheet.md | 2 +- 9 files changed, 24 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41708b67..5b5f30fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- `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 diff --git a/README.md b/README.md index 00bf5708..da6a3387 100644 --- a/README.md +++ b/README.md @@ -338,9 +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`. 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`. +`--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 diff --git a/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift b/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift index b490a2ef..46824a45 100644 --- a/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift +++ b/Sources/AndroidBackend/Verbs/AndroidRecordVideoCommand.swift @@ -44,8 +44,8 @@ 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 + @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-. in the current directory.") public var output: String? @@ -113,7 +113,7 @@ public struct AndroidRecordVideoCommand: SimUseExecutableCommand { fps: Int?, quality: Int, scale: Double?, - gifMarkers: Bool = true + gifMarkers: Bool = false ) async throws -> URL { let adb = Adb() try assertAdbDeviceOnline(adb: adb, serial: serial) diff --git a/Sources/SimUse/Commands/RecordVideo.swift b/Sources/SimUse/Commands/RecordVideo.swift index a14ad306..f78aa668 100644 --- a/Sources/SimUse/Commands/RecordVideo.swift +++ b/Sources/SimUse/Commands/RecordVideo.swift @@ -36,8 +36,8 @@ 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 + @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-. in the current directory.") var output: String? diff --git a/Sources/SimUseVideo/GIFTranscoder.swift b/Sources/SimUseVideo/GIFTranscoder.swift index b8b06f2e..a32eba45 100644 --- a/Sources/SimUseVideo/GIFTranscoder.swift +++ b/Sources/SimUseVideo/GIFTranscoder.swift @@ -131,12 +131,12 @@ public enum GIFTranscoder { static let markerDelay: Double = 1.0 /// Transcode `mp4URL` into an animated GIF at `gifURL`, sampling at - /// `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 + /// `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, markers: Bool = true) 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 @@ -203,7 +203,7 @@ 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, markers: Bool = true) 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, markers: markers) diff --git a/Sources/SimUseVideo/RecordingFormat.swift b/Sources/SimUseVideo/RecordingFormat.swift index 41853267..aae3094a 100644 --- a/Sources/SimUseVideo/RecordingFormat.swift +++ b/Sources/SimUseVideo/RecordingFormat.swift @@ -42,7 +42,7 @@ public struct ResolvedRecordingOptions: Equatable, Sendable { /// Whether a GIF is bracketed with START/END marker frames. public let gifMarkers: Bool - public init(format: RecordingFormat?, output: String?, fps: Int?, scale: Double?, gifMarkers: Bool = true) { + 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 @@ -72,7 +72,7 @@ public struct RecordingOutputPlan { public let outputURL: URL public let recordTarget: URL - public init(format: RecordingFormat?, output: String?, fps: Int?, scale: Double?, gifMarkers: Bool = true) throws { + 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) diff --git a/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift b/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift index 72416c09..fe9765a5 100644 --- a/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift +++ b/Sources/iOSSimBackend/Verbs/IOSSimRecordVideoCommand.swift @@ -50,8 +50,8 @@ 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 + @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-. in the current directory.") public var output: String? diff --git a/Tests/GIFTranscoderTests.swift b/Tests/GIFTranscoderTests.swift index 270bb03b..22ed9c74 100644 --- a/Tests/GIFTranscoderTests.swift +++ b/Tests/GIFTranscoderTests.swift @@ -111,7 +111,7 @@ struct GIFTranscoderTests { #expect(gif.fps == 10) #expect(gif.gifSampleFPS == 10) #expect(gif.scale == 0.5) - #expect(gif.gifMarkers) // default on + #expect(!gif.gifMarkers) // opt-in, default off let inferred = Options(format: nil, output: "demo.gif", fps: nil, scale: nil) #expect(inferred.format == .gif) @@ -202,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, markers: false) + let written = try await GIFTranscoder.transcode(mp4URL: mp4URL, to: gifURL, fps: 10) let source = try #require(CGImageSourceCreateWithURL(gifURL as CFURL, nil)) let type = try #require(CGImageSourceGetType(source) as String?) @@ -234,12 +234,12 @@ 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, markers: false) + let written = try await GIFTranscoder.transcode(mp4URL: mp4URL, to: gifURL, fps: 10) #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") + @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) } @@ -247,7 +247,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: true) let source = try #require(CGImageSourceCreateWithURL(gifURL as CFURL, nil)) let frameCount = CGImageSourceGetCount(source) diff --git a/skills/sim-use/references/cheatsheet.md b/skills/sim-use/references/cheatsheet.md index b3b6cc2c..65db6c82 100644 --- a/skills/sim-use/references/cheatsheet.md +++ b/skills/sim-use/references/cheatsheet.md @@ -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; START/END marker cards (--no-gif-markers to skip); 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) ```