Skip to content

Instantly share code, notes, and snippets.

@bsidhom
Last active June 25, 2023 06:51
Show Gist options
  • Save bsidhom/27e97fccd02c659073c5f584d6670389 to your computer and use it in GitHub Desktop.
Save bsidhom/27e97fccd02c659073c5f584d6670389 to your computer and use it in GitHub Desktop.
Capture a photo every second and turn the input into a 30 fps h.264 video.
// This file should live under Sources/main.swift, but GitHub gists don't allow slashes in file names.
import AVFoundation
import AppKit
import CoreGraphics
import Foundation
class WebcamCapture: NSObject, AVCapturePhotoCaptureDelegate {
let captureSession = AVCaptureSession()
var photoOutput = AVCapturePhotoOutput()
var saveDirectory: String
var filePrefix: String
var capturePeriod: Double
var sequenceNumber = 0
init(saveDirectory: String, filePrefix: String, capturePeriod: Double) {
self.saveDirectory = saveDirectory
self.filePrefix = filePrefix
self.capturePeriod = capturePeriod
}
func startCapture() {
captureSession.sessionPreset = .high
guard let device = AVCaptureDevice.default(for: .video) else {
print("No camera found")
return
}
guard let deviceInput = try? AVCaptureDeviceInput(device: device),
captureSession.canAddInput(deviceInput) else {
print("Error adding camera input")
return
}
captureSession.addInput(deviceInput)
guard captureSession.canAddOutput(photoOutput) else {
print("Error adding photo output")
return
}
captureSession.addOutput(photoOutput)
captureSession.startRunning()
let timer = Timer.scheduledTimer(withTimeInterval: capturePeriod, repeats: true) { timer in
let settings = AVCapturePhotoSettings()
self.photoOutput.capturePhoto(with: settings, delegate: self)
}
timer.tolerance = 0.01 // Tolerance in seconds
RunLoop.main.run()
}
func photoOutput(_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?) {
guard let imageData = photo.fileDataRepresentation() else {
print("Error capturing photo")
return
}
let timestamp = Date()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
let timestampText = formatter.string(from: timestamp)
guard let imageWithTimestamp = drawText(text: timestampText, inImage: imageData) else {
print("Error adding timestamp to photo")
return
}
sequenceNumber += 1
let fileName = String(format: "\(filePrefix)-%05d.jpg", sequenceNumber)
let filePath = (saveDirectory as NSString).appendingPathComponent(fileName)
do {
try imageWithTimestamp.write(to: URL(fileURLWithPath: filePath))
print("Saved photo to \(filePath)")
} catch {
print("Error saving photo: \(error)")
}
}
func drawText(text: String, inImage imageData: Data) -> Data? {
guard let image = NSImage(data: imageData) else {
return nil
}
let textAttributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 18),
.foregroundColor: NSColor.white
]
let textRect = CGRect(x: 10, y: 10, width: image.size.width, height: 40)
let imageRep = NSBitmapImageRep(data: imageData)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: imageRep!)
NSString(string: text).draw(in: textRect, withAttributes: textAttributes)
NSGraphicsContext.restoreGraphicsState()
guard let outputData = imageRep?.representation(using: .jpeg, properties: [:]) else {
return nil
}
return outputData
}
}
guard CommandLine.arguments.count == 4 else {
print("Usage: WebcamCapture <saveDirectory> <filePrefix> <capturePeriodInSeconds>")
exit(1)
}
let saveDirectory = CommandLine.arguments[1]
let filePrefix = CommandLine.arguments[2]
let capturePeriod = Double(CommandLine.arguments[3]) ?? 1.0
let webcamCapture = WebcamCapture(saveDirectory: saveDirectory, filePrefix: filePrefix, capturePeriod: capturePeriod)
webcamCapture.startCapture()
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "WebcamCapture",
platforms: [
.macOS(.v10_15)
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.executableTarget(
name: "WebcamCapture",
path: "Sources"),
]
)
#!/usr/bin/env python3
import argparse
import asyncio
import os
import subprocess
async def main():
parser = argparse.ArgumentParser(
description="Create a video from a stream of still images")
parser.add_argument(
'-i',
'--input_directory',
type=str,
required=True,
help=
"Directory into which still images will be dropped. Image names should be in increasing lexicographical C string order for the filesystem's encoding scheme. Typically, byte-ordered ASCII strings are sufficient."
)
parser.add_argument(
'-o',
'--output_file',
type=str,
required=True,
help=
"Output video prefix. The final video path will be <prefix>-%%05d.mp4")
parser.add_argument('-f',
'--frame_rate',
type=float,
default=30.0,
help="Target frame rate (frames per second).")
parser.add_argument('-n',
'--number_of_frames',
type=int,
default=3600,
help="Maximum number of frames per video.")
args = parser.parse_args()
# Command to run FFmpeg as a subprocess
ffmpeg_command = [
'ffmpeg', '-f', 'image2pipe', '-framerate',
str(args.frame_rate), '-i', '-', '-c:v', 'libx264', '-pix_fmt',
'yuv420p', args.output_file
]
# Start FFmpeg as a subprocess with stdin as a pipe
ffmpeg_process = await asyncio.create_subprocess_exec(
*ffmpeg_command, stdin=subprocess.PIPE)
# Feed images to FFmpeg
await feed_images_to_ffmpeg(ffmpeg_process.stdin, args.input_directory,
args.number_of_frames)
# Wait for FFmpeg to exit and check the exit code
await ffmpeg_process.wait()
if ffmpeg_process.returncode != 0:
raise Exception(
f"FFmpeg exited with an error code: {ffmpeg_process.returncode}")
async def feed_images_to_ffmpeg(ffmpeg_stdin, input_directory,
number_of_frames):
image_count = 0
for filename in sorted(os.listdir(input_directory)):
if filename.endswith('.jpg') and image_count < number_of_frames:
file_path = os.path.join(input_directory, filename)
with open(file_path, 'rb') as image_file:
# Write the image file to FFmpeg's stdin
ffmpeg_stdin.write(image_file.read())
await ffmpeg_stdin.drain()
image_count += 1
# Close FFmpeg's stdin to let it finish encoding
ffmpeg_stdin.close()
await ffmpeg_stdin.wait_closed()
if __name__ == "__main__":
asyncio.run(main())
@bsidhom
Copy link
Author

bsidhom commented Jun 25, 2023

To build the Swift program:

swift build -c release

And to run it:

./.build/release/WebcamCapture <target directory> <photo prefix> <capture period in seconds>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment