Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(lsof:*)",
"Bash(log show:*)",
"Read(//Users/ccarpio/Library/Developer/Xcode/DerivedData/**)"
]
}
}
4 changes: 2 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ set(CMAKE_OSX_DEPLOYMENT_TARGET 11.0)

project(PiP)

add_compile_options(-fobjc-arc -Wno-deprecated-declarations -Wno-format)
add_compile_options(-fobjc-arc -Wno-deprecated-declarations -Wno-format -mmacosx-version-min=14.0)

set(frameworks AVFoundation Cocoa VideoToolbox AudioToolbox CoreMedia QuartzCore OpenGL Metal MetalKit PIP SkyLight ScreenCaptureKit)
list(TRANSFORM frameworks PREPEND "-framework ")

set(AIRPLAY_SUPPORT_ENABLED 1)

file(GLOB_RECURSE pip_src CONFIGURE_DEPENDS "pip/*.m")
file(GLOB_RECURSE pip_src CONFIGURE_DEPENDS "pip/*.m" "pip/*.c")
add_executable(pip ${pip_src})
target_include_directories(pip PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
Expand Down
1 change: 1 addition & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
MIT License

Copyright (c) 2020 amitv87
Copyright (c) 2026 ccarpiog

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ Always on top window preview with AirPlay receiver support (if on macOS 12+, tur
## Features
* Clone any visibile window
* Clone multiple active display
* Camera preview
* HLS streaming
* Camera preview with audio monitoring
* HLS live streaming of any preview (window, display, or camera)
* Airplay sender (unstable)
* Crop the preview
* Auto and manual resize preserving the aspect ratio
Expand All @@ -30,6 +30,40 @@ Always on top window preview with AirPlay receiver support (if on macOS 12+, tur
* Minimal modern UI
* Upto 10 parallel airplay sessions (soft limit)

## Live Streaming

PiP can stream any preview window over your local network using HLS (HTTP Live Streaming). Any device with a web browser on the same network can watch the live feed.

### How to use
1. Right-click any active preview window and select **Stream > Start Streaming**
2. The stream URL (e.g. `http://192.168.1.42:8080`) appears at the bottom of the Stream menu
3. Open that URL on any device's browser to watch the live stream
4. Use **Copy URL** to copy the direct HLS stream URL (`/stream.m3u8`) or **Open in Browser** to open the web viewer

### Quality presets
| Preset | Resolution | Bitrate | FPS |
|--------|-----------|---------|-----|
| Low | 720p | 1.5 Mbps | 24 |
| Medium | 1080p | 3 Mbps | 30 |
| High | Native | 6 Mbps | 30 |

Change quality on the fly via **Stream > Quality**.

### Architecture
The streaming pipeline is fully self-contained with no third-party dependencies:

```
Frame Capture → H.264 Video Encoder → MPEG-TS Muxer → HLS Writer → HTTP Server
Audio Capture
```

* **Frame Capture** – grabs RGBA frames from the preview's OpenGL/Metal renderer
* **Video Encoder** – hardware-accelerated H.264 encoding via VideoToolbox
* **TS Muxer** – multiplexes video (and optional audio) into MPEG-TS segments
* **HLS Writer** – manages a ring buffer of `.ts` segments and generates the `.m3u8` playlist
* **HTTP Server** – lightweight embedded server that serves the playlist, segments, and a built-in web viewer with hls.js

## Installation

### Manual download
Expand Down
9 changes: 8 additions & 1 deletion airplay_sender/video_encoder.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ video_encoder_t *video_encoder_init(int width, int height, int fps, int bitrate)
*/
void video_encoder_set_callback(video_encoder_t *enc, encoded_frame_callback_t cb, void *ctx);

/**
* Set maximum keyframe interval in frames.
* Example: at 30fps, 30 => ~1 second keyframe cadence.
* Returns 0 on success, -1 on error.
*/
int video_encoder_set_keyframe_interval(video_encoder_t *enc, int keyframe_interval_frames);

/**
* Encode a frame
* rgba_data: RGBA32 format frame data (row-major)
Expand All @@ -78,4 +85,4 @@ void video_encoder_destroy(video_encoder_t *enc);
}
#endif

#endif
#endif
8 changes: 8 additions & 0 deletions airplay_sender/video_encoder_stub.c
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ video_encoder_set_callback(video_encoder_t *enc, encoded_frame_callback_t cb, vo
}
}

int
video_encoder_set_keyframe_interval(video_encoder_t *enc, int keyframe_interval_frames)
{
(void)enc;
(void)keyframe_interval_frames;
return 0;
}

int
video_encoder_encode_frame(video_encoder_t *enc, uint8_t *rgba_data, int stride, uint64_t pts)
{
Expand Down
30 changes: 30 additions & 0 deletions pip.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@
55FEB9A7 /* airplaySender.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEB9A2 /* airplaySender.m */; };
55FEB9A8 /* frame_capture.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEB9A4 /* frame_capture.m */; };
55FEB9A9 /* video_encoder.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEB9A6 /* video_encoder.m */; };
55FEBC11 /* ts_muxer.c in Sources */ = {isa = PBXBuildFile; fileRef = 55FEBC02 /* ts_muxer.c */; };
55FEBC12 /* hls_writer.c in Sources */ = {isa = PBXBuildFile; fileRef = 55FEBC04 /* hls_writer.c */; };
55FEBC13 /* stream_server.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEBC06 /* stream_server.m */; };
55FEBC14 /* stream_manager.m in Sources */ = {isa = PBXBuildFile; fileRef = 55FEBC08 /* stream_manager.m */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -325,6 +329,17 @@
55FEB9A5 /* video_encoder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = video_encoder.h; sourceTree = "<group>"; };
55FEB9A6 /* video_encoder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = video_encoder.m; sourceTree = "<group>"; };
55FEB9AA /* ScreenCaptureKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ScreenCaptureKit.framework; path = /System/Volumes/Data/Library/Developer/CommandLineTools/SDKs/MacOSX12.3.sdk/System/Library/Frameworks/ScreenCaptureKit.framework; sourceTree = "<group>"; };
55FEBC01 /* ts_muxer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ts_muxer.h; sourceTree = "<group>"; };
55FEBC02 /* ts_muxer.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = ts_muxer.c; sourceTree = "<group>"; };
55FEBC03 /* hls_writer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hls_writer.h; sourceTree = "<group>"; };
55FEBC04 /* hls_writer.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hls_writer.c; sourceTree = "<group>"; };
55FEBC05 /* stream_server.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = stream_server.h; sourceTree = "<group>"; };
55FEBC06 /* stream_server.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = stream_server.m; sourceTree = "<group>"; };
55FEBC07 /* stream_manager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = stream_manager.h; sourceTree = "<group>"; };
55FEBC08 /* stream_manager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = stream_manager.m; sourceTree = "<group>"; };
55FEBC09 /* viewer.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = viewer.html; sourceTree = "<group>"; };
55FEBC0A /* hls.min.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "hls.min.js"; sourceTree = "<group>"; };
55FEBC0B /* incbin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = incbin.h; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -576,6 +591,17 @@
55FEB9A4 /* frame_capture.m */,
55FEB9A5 /* video_encoder.h */,
55FEB9A6 /* video_encoder.m */,
55FEBC01 /* ts_muxer.h */,
55FEBC02 /* ts_muxer.c */,
55FEBC03 /* hls_writer.h */,
55FEBC04 /* hls_writer.c */,
55FEBC05 /* stream_server.h */,
55FEBC06 /* stream_server.m */,
55FEBC07 /* stream_manager.h */,
55FEBC08 /* stream_manager.m */,
55FEBC09 /* viewer.html */,
55FEBC0A /* hls.min.js */,
55FEBC0B /* incbin.h */,
);
path = pip;
sourceTree = "<group>";
Expand Down Expand Up @@ -807,6 +833,10 @@
55FEB9A7 /* airplaySender.m in Sources */,
55FEB9A8 /* frame_capture.m in Sources */,
55FEB9A9 /* video_encoder.m in Sources */,
55FEBC11 /* ts_muxer.c in Sources */,
55FEBC12 /* hls_writer.c in Sources */,
55FEBC13 /* stream_server.m in Sources */,
55FEBC14 /* stream_manager.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
2 changes: 2 additions & 0 deletions pip/HLSPlayer.m
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ - (instancetype)initWithURL:(NSURL *)url headers:(NSDictionary<NSString *, NSStr

_playerItem = [AVPlayerItem playerItemWithAsset:asset];
_player = [AVPlayer playerWithPlayerItem:_playerItem];
_player.automaticallyWaitsToMinimizeStalling = NO;
_playerItem.preferredForwardBufferDuration = 1.0;

// Setup video output for frame extraction
NSDictionary *pixBuffAttributes = @{
Expand Down
34 changes: 23 additions & 11 deletions pip/frame_capture.m
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,29 @@ static void capture_frame(frame_capture_t *cap) {
return;
}

// Get the current image from the renderer (must be on main thread)
// __block CIImage *currentImage = nil;
// if ([NSThread isMainThread]) {
// currentImage = [imageView.renderer currentImage];
// } else {
// dispatch_sync(dispatch_get_main_queue(), ^{
// currentImage = [imageView.renderer currentImage];
// });
// }

__block CIImage *currentImage = [imageView.renderer currentImage];
// Get the current image from the renderer on main thread.
// Use a bounded wait when called from the capture queue to avoid deadlock
// during shutdown (main thread stopping capture while capture queue requests main).
__block CIImage *currentImage = nil;
if ([NSThread isMainThread]) {
currentImage = [imageView.renderer currentImage];
} else {
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
dispatch_async(dispatch_get_main_queue(), ^{
currentImage = [imageView.renderer currentImage];
dispatch_semaphore_signal(sem);
});
long wait_result = dispatch_semaphore_wait(
sem,
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(50 * NSEC_PER_MSEC))
);
if (wait_result != 0) {
if (cap->frame_count == 0 || cap->frame_count % 30 == 0) {
NSLog(@"frame_capture: timed out waiting for main-thread image (frame_count: %llu)", cap->frame_count);
}
return;
}
}

if (!currentImage) {
if (cap->frame_count == 0 || cap->frame_count % 30 == 0) {
Expand Down
2 changes: 2 additions & 0 deletions pip/hls.min.js

Large diffs are not rendered by default.

Loading