Last active
June 25, 2023 06:51
-
-
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 contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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"), | |
] | |
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
To build the Swift program:
And to run it: