This commit is contained in:
ApfelTeeSaft
2025-10-28 19:17:52 +01:00
parent 13891f15c5
commit 8a3a09c520
20 changed files with 5958 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Build artifacts
dist/
*.3dsx
*.elf
*.smdh
*.cia
# 3DS build
3ds-app/build/
*.o
*.d
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Config (user-specific)
config.local.json
+260
View File
@@ -0,0 +1,260 @@
#---------------------------------------------------------------------------------
.SUFFIXES:
#---------------------------------------------------------------------------------
ifeq ($(strip $(DEVKITARM)),)
$(error "Please set DEVKITARM in your environment. export DEVKITARM=<path to>devkitARM")
endif
TOPDIR ?= $(CURDIR)
include $(DEVKITARM)/3ds_rules
#---------------------------------------------------------------------------------
# TARGET is the name of the output
# BUILD is the directory where object files & intermediate files will be placed
# SOURCES is a list of directories containing source code
# DATA is a list of directories containing data files
# INCLUDES is a list of directories containing header files
# GRAPHICS is a list of directories containing graphics files
# GFXBUILD is the directory where converted graphics files will be placed
# If set to $(BUILD), it will statically link in the converted
# files as if they were data files.
#
# NO_SMDH: if set to anything, no SMDH file is generated.
# ROMFS is the directory which contains the RomFS, relative to the Makefile (Optional)
# APP_TITLE is the name of the app stored in the SMDH file (Optional)
# APP_DESCRIPTION is the description of the app stored in the SMDH file (Optional)
# APP_AUTHOR is the author of the app stored in the SMDH file (Optional)
# ICON is the filename of the icon (.png), relative to the project folder.
# If not set, it attempts to use one of the following (in this order):
# - <Project name>.png
# - icon.png
# - <libctru folder>/default_icon.png
#---------------------------------------------------------------------------------
TARGET := 3ds-webcam-bridge
BUILD := build
SOURCES := source
DATA := data
INCLUDES := source source/third_party
GRAPHICS := gfx
#ROMFS := romfs
APP_TITLE := 3DS Webcam Bridge
APP_DESCRIPTION := Stream 3DS cameras to PC as virtual webcam
APP_AUTHOR := CTR-WebCam Contributors
#---------------------------------------------------------------------------------
# options for code generation
#---------------------------------------------------------------------------------
ARCH := -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft
CFLAGS := -g -Wall -O2 -mword-relocations \
-ffunction-sections \
$(ARCH)
CFLAGS += $(INCLUDE) -D__3DS__
CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions -std=gnu++11
ASFLAGS := -g $(ARCH)
LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
LIBS := -lctru -lm
#---------------------------------------------------------------------------------
# list of directories containing libraries, this must be the top level containing
# include and lib
#---------------------------------------------------------------------------------
LIBDIRS := $(CTRULIB)
#---------------------------------------------------------------------------------
# no real need to edit anything past this point unless you need to add additional
# rules for different file extensions
#---------------------------------------------------------------------------------
ifneq ($(BUILD),$(notdir $(CURDIR)))
#---------------------------------------------------------------------------------
export OUTPUT := $(CURDIR)/../dist/$(TARGET)
export TOPDIR := $(CURDIR)
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \
$(foreach dir,$(GRAPHICS),$(CURDIR)/$(dir)) \
$(foreach dir,$(DATA),$(CURDIR)/$(dir))
export DEPSDIR := $(CURDIR)/$(BUILD)
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp)))
SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s)))
PICAFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.v.pica)))
SHLISTFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.shlist)))
GFXFILES := $(foreach dir,$(GRAPHICS),$(notdir $(wildcard $(dir)/*.t3s)))
BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*)))
#---------------------------------------------------------------------------------
# use CXX for linking C++ projects, CC for standard C
#---------------------------------------------------------------------------------
ifeq ($(strip $(CPPFILES)),)
#---------------------------------------------------------------------------------
export LD := $(CC)
#---------------------------------------------------------------------------------
else
#---------------------------------------------------------------------------------
export LD := $(CXX)
#---------------------------------------------------------------------------------
endif
#---------------------------------------------------------------------------------
#---------------------------------------------------------------------------------
ifeq ($(GFXBUILD),$(BUILD))
#---------------------------------------------------------------------------------
export T3XFILES := $(GFXFILES:.t3s=.t3x)
#---------------------------------------------------------------------------------
else
#---------------------------------------------------------------------------------
export ROMFS_T3XFILES := $(patsubst %.t3s, $(GFXBUILD)/%.t3x, $(GFXFILES))
export T3XHFILES := $(patsubst %.t3s, $(BUILD)/%.h, $(GFXFILES))
#---------------------------------------------------------------------------------
endif
#---------------------------------------------------------------------------------
export OFILES_SOURCES := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o)
export OFILES_BIN := $(addsuffix .o,$(BINFILES)) \
$(PICAFILES:.v.pica=.shbin.o) $(SHLISTFILES:.shlist=.shbin.o) \
$(addsuffix .o,$(T3XFILES))
export OFILES := $(OFILES_BIN) $(OFILES_SOURCES)
export HFILES := $(PICAFILES:.v.pica=_shbin.h) $(SHLISTFILES:.shlist=_shbin.h) \
$(addsuffix .h,$(subst .,_,$(BINFILES))) \
$(GFXFILES:.t3s=.h)
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
-I$(CURDIR)/$(BUILD)
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
export _3DSXDEPS := $(if $(NO_SMDH),,$(OUTPUT).smdh)
ifeq ($(strip $(ICON)),)
icons := $(wildcard *.png)
ifneq (,$(findstring $(TARGET).png,$(icons)))
export APP_ICON := $(TOPDIR)/$(TARGET).png
else
ifneq (,$(findstring icon.png,$(icons)))
export APP_ICON := $(TOPDIR)/icon.png
endif
endif
else
export APP_ICON := $(TOPDIR)/$(ICON)
endif
ifeq ($(strip $(NO_SMDH)),)
export _3DSXFLAGS += --smdh=$(CURDIR)/../dist/$(TARGET).smdh
endif
ifneq ($(ROMFS),)
export _3DSXFLAGS += --romfs=$(CURDIR)/$(ROMFS)
endif
.PHONY: all clean
#---------------------------------------------------------------------------------
all: $(BUILD) $(GFXBUILD) $(DEPSDIR) $(ROMFS_T3XFILES) $(T3XHFILES)
@mkdir -p $(CURDIR)/../dist
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
$(BUILD):
@mkdir -p $@
ifneq ($(GFXBUILD),$(BUILD))
$(GFXBUILD):
@mkdir -p $@
endif
ifneq ($(DEPSDIR),$(BUILD))
$(DEPSDIR):
@mkdir -p $@
endif
#---------------------------------------------------------------------------------
clean:
@echo clean ...
@rm -fr $(BUILD) $(GFXBUILD) $(TARGET).3dsx $(OUTPUT).smdh $(TARGET).elf
@rm -fr $(CURDIR)/../dist/$(TARGET).*
#---------------------------------------------------------------------------------
$(GFXBUILD)/%.t3x $(BUILD)/%.h : %.t3s
#---------------------------------------------------------------------------------
@echo $(notdir $<)
@tex3ds -i $< -H $(BUILD)/$*.h -d $(DEPSDIR)/$*.d -o $(GFXBUILD)/$*.t3x
#---------------------------------------------------------------------------------
else
#---------------------------------------------------------------------------------
# main targets
#---------------------------------------------------------------------------------
$(OUTPUT).3dsx : $(OUTPUT).elf $(_3DSXDEPS)
$(OFILES_SOURCES) : $(HFILES)
$(OUTPUT).elf : $(OFILES)
#---------------------------------------------------------------------------------
# you need a rule like this for each extension you use as binary data
#---------------------------------------------------------------------------------
%.bin.o %_bin.h : %.bin
#---------------------------------------------------------------------------------
@echo $(notdir $<)
@$(bin2o)
#---------------------------------------------------------------------------------
.PRECIOUS : %.t3x
#---------------------------------------------------------------------------------
%.t3x.o %_t3x.h : %.t3x
#---------------------------------------------------------------------------------
@echo $(notdir $<)
@$(bin2o)
#---------------------------------------------------------------------------------
# rules for assembling GPU shaders
#---------------------------------------------------------------------------------
define shader-as
$(eval CURBIN := $*.shbin)
$(eval DEPSFILE := $(DEPSDIR)/$*.shbin.d)
echo "$(CURBIN).o: $< $1" > $(DEPSFILE)
echo "extern const u8" `(echo $(CURBIN) | sed -e 's/^\([0-9]\)/_\1/' | tr . _)`"_end[];" > `(echo $(CURBIN) | tr . _)`.h
echo "extern const u8" `(echo $(CURBIN) | sed -e 's/^\([0-9]\)/_\1/' | tr . _)`"[];" >> `(echo $(CURBIN) | tr . _)`.h
echo "extern const u32" `(echo $(CURBIN) | sed -e 's/^\([0-9]\)/_\1/' | tr . _)`_size";" >> `(echo $(CURBIN) | tr . _)`.h
picasso -o $(CURBIN) $1
bin2s $(CURBIN) | $(AS) -o $*.shbin.o
endef
%.shbin.o %_shbin.h : %.v.pica %.g.pica
@echo $(notdir $^)
@$(call shader-as,$^)
%.shbin.o %_shbin.h : %.v.pica
@echo $(notdir $<)
@$(call shader-as,$<)
%.shbin.o %_shbin.h : %.shlist
@echo $(notdir $<)
@$(call shader-as,$(foreach file,$(shell cat $<),$(dir $<)$(file)))
#---------------------------------------------------------------------------------
%.t3x %.h : %.t3s
#---------------------------------------------------------------------------------
@echo $(notdir $<)
@tex3ds -i $< -H $*.h -d $*.d -o $*.t3x
-include $(DEPSDIR)/*.d
#---------------------------------------------------------------------------------------
endif
#---------------------------------------------------------------------------------------
+369
View File
@@ -0,0 +1,369 @@
#include "camera_stream.h"
#include <malloc.h>
#include <string.h>
#include <arpa/inet.h>
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "third_party/stb_image_write.h"
// Global buffers
static uint16_t* cam_buffer_left = NULL;
static uint16_t* cam_buffer_right = NULL;
static uint8_t* rgb_buffer = NULL;
// Camera handles
static u32 cam_left = 0;
static u32 cam_right = 0;
static bool cameras_active = false;
// STB callback for JPEG encoding
static void stb_write_callback(void* context, void* data, int size) {
JpegBuffer* buf = (JpegBuffer*)context;
// Resize if needed
if (buf->size + size > buf->capacity) {
size_t new_capacity = buf->capacity * 2;
if (new_capacity < buf->size + size) {
new_capacity = buf->size + size;
}
uint8_t* new_data = realloc(buf->data, new_capacity);
if (!new_data) return;
buf->data = new_data;
buf->capacity = new_capacity;
}
memcpy(buf->data + buf->size, data, size);
buf->size += size;
}
JpegBuffer* jpeg_buffer_create(size_t initial_capacity) {
JpegBuffer* buf = malloc(sizeof(JpegBuffer));
if (!buf) return NULL;
buf->data = malloc(initial_capacity);
if (!buf->data) {
free(buf);
return NULL;
}
buf->size = 0;
buf->capacity = initial_capacity;
return buf;
}
void jpeg_buffer_free(JpegBuffer* buf) {
if (buf) {
if (buf->data) free(buf->data);
free(buf);
}
}
Result camera_init(void) {
Result ret = camInit();
if (R_FAILED(ret)) return ret;
// Allocate buffers
cam_buffer_left = memalign(0x80, CAM_BUFFER_SIZE);
cam_buffer_right = memalign(0x80, CAM_BUFFER_SIZE);
rgb_buffer = malloc(RGB_BUFFER_SIZE);
if (!cam_buffer_left || !cam_buffer_right || !rgb_buffer) {
camera_shutdown();
return -1;
}
return 0;
}
void camera_shutdown(void) {
if (cameras_active) {
camera_stop();
}
if (cam_buffer_left) {
free(cam_buffer_left);
cam_buffer_left = NULL;
}
if (cam_buffer_right) {
free(cam_buffer_right);
cam_buffer_right = NULL;
}
if (rgb_buffer) {
free(rgb_buffer);
rgb_buffer = NULL;
}
camExit();
}
Result camera_start(CameraMode mode) {
Result ret;
if (cameras_active) {
camera_stop();
}
// Select camera(s)
u32 select;
switch (mode) {
case CAM_MODE_INNER:
select = SELECT_IN1;
break;
case CAM_MODE_OUTER_MONO:
case CAM_MODE_OUTER_STEREO:
select = SELECT_OUT1 | SELECT_OUT2;
break;
default:
return -1;
}
// Set camera parameters
ret = CAMU_SetSize(select, SIZE_CTR_TOP_LCD, CONTEXT_A);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetOutputFormat(select, OUTPUT_YUV_422, CONTEXT_A);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetFrameRate(select, FRAME_RATE_15);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetNoiseFilter(select, true);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetAutoExposure(select, true);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetAutoWhiteBalance(select, true);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetTrimming(select, false);
if (R_FAILED(ret)) return ret;
// Activate cameras
ret = CAMU_Activate(select);
if (R_FAILED(ret)) return ret;
cameras_active = true;
return 0;
}
void camera_stop(void) {
if (cameras_active) {
CAMU_Activate(SELECT_NONE);
cameras_active = false;
}
}
void yuv422_to_rgb24(const uint16_t* yuv, uint8_t* rgb, int width, int height) {
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x += 2) {
int idx = y * width + x;
uint16_t px1 = yuv[idx];
uint16_t px2 = yuv[idx + 1];
// YUV422: [Y0 U Y1 V] packed in 16-bit values
int y0 = (px1 >> 8) & 0xFF;
int u = px1 & 0xFF;
int y1 = (px2 >> 8) & 0xFF;
int v = px2 & 0xFF;
// YUV to RGB conversion
int c0 = y0 - 16;
int c1 = y1 - 16;
int d = u - 128;
int e = v - 128;
// Pixel 0
int r0 = (298 * c0 + 409 * e + 128) >> 8;
int g0 = (298 * c0 - 100 * d - 208 * e + 128) >> 8;
int b0 = (298 * c0 + 516 * d + 128) >> 8;
// Pixel 1
int r1 = (298 * c1 + 409 * e + 128) >> 8;
int g1 = (298 * c1 - 100 * d - 208 * e + 128) >> 8;
int b1 = (298 * c1 + 516 * d + 128) >> 8;
// Clamp
r0 = r0 < 0 ? 0 : (r0 > 255 ? 255 : r0);
g0 = g0 < 0 ? 0 : (g0 > 255 ? 255 : g0);
b0 = b0 < 0 ? 0 : (b0 > 255 ? 255 : b0);
r1 = r1 < 0 ? 0 : (r1 > 255 ? 255 : r1);
g1 = g1 < 0 ? 0 : (g1 > 255 ? 255 : g1);
b1 = b1 < 0 ? 0 : (b1 > 255 ? 255 : b1);
// Write RGB
int out_idx = (y * width + x) * 3;
rgb[out_idx + 0] = r0;
rgb[out_idx + 1] = g0;
rgb[out_idx + 2] = b0;
rgb[out_idx + 3] = r1;
rgb[out_idx + 4] = g1;
rgb[out_idx + 5] = b1;
}
}
}
Result camera_capture_frame(CameraMode mode, JpegBuffer* left, JpegBuffer* right) {
Result ret;
u32 transfer_size = 0;
// Reset JPEG buffer sizes
if (left) left->size = 0;
if (right) right->size = 0;
switch (mode) {
case CAM_MODE_INNER:
case CAM_MODE_OUTER_MONO: {
// Capture left/main camera only
u32 cam_select = (mode == CAM_MODE_INNER) ? SELECT_IN1 : SELECT_OUT1;
ret = CAMU_GetMaxBytes(&transfer_size, CAM_WIDTH, CAM_HEIGHT);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetTransferBytes(cam_select, transfer_size, CAM_WIDTH, CAM_HEIGHT);
if (R_FAILED(ret)) return ret;
ret = CAMU_ClearBuffer(PORT_CAM1);
if (R_FAILED(ret)) return ret;
ret = CAMU_StartCapture(cam_select);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetReceiving(&cam_left, cam_buffer_left, cam_select, transfer_size, 0);
if (R_FAILED(ret)) return ret;
// Wait for transfer
svcWaitSynchronization(cam_left, U64_MAX);
svcCloseHandle(cam_left);
ret = CAMU_StopCapture(cam_select);
if (R_FAILED(ret)) return ret;
// Convert to RGB
yuv422_to_rgb24(cam_buffer_left, rgb_buffer, CAM_WIDTH, CAM_HEIGHT);
// Encode JPEG
if (left) {
stbi_write_jpg_to_func(stb_write_callback, left, CAM_WIDTH, CAM_HEIGHT, 3,
rgb_buffer, JPEG_QUALITY);
}
break;
}
case CAM_MODE_OUTER_STEREO: {
// Capture both outer cameras
ret = CAMU_GetMaxBytes(&transfer_size, CAM_WIDTH, CAM_HEIGHT);
if (R_FAILED(ret)) return ret;
// Left camera (OUT1)
ret = CAMU_SetTransferBytes(SELECT_OUT1, transfer_size, CAM_WIDTH, CAM_HEIGHT);
if (R_FAILED(ret)) return ret;
ret = CAMU_ClearBuffer(PORT_CAM1);
if (R_FAILED(ret)) return ret;
ret = CAMU_StartCapture(SELECT_OUT1);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetReceiving(&cam_left, cam_buffer_left, SELECT_OUT1, transfer_size, 0);
if (R_FAILED(ret)) return ret;
// Right camera (OUT2)
ret = CAMU_SetTransferBytes(SELECT_OUT2, transfer_size, CAM_WIDTH, CAM_HEIGHT);
if (R_FAILED(ret)) return ret;
ret = CAMU_ClearBuffer(PORT_CAM2);
if (R_FAILED(ret)) return ret;
ret = CAMU_StartCapture(SELECT_OUT2);
if (R_FAILED(ret)) return ret;
ret = CAMU_SetReceiving(&cam_right, cam_buffer_right, SELECT_OUT2, transfer_size, 0);
if (R_FAILED(ret)) return ret;
// Wait for both transfers
svcWaitSynchronization(cam_left, U64_MAX);
svcWaitSynchronization(cam_right, U64_MAX);
svcCloseHandle(cam_left);
svcCloseHandle(cam_right);
ret = CAMU_StopCapture(SELECT_OUT1 | SELECT_OUT2);
if (R_FAILED(ret)) return ret;
// Convert left to RGB
yuv422_to_rgb24(cam_buffer_left, rgb_buffer, CAM_WIDTH, CAM_HEIGHT);
if (left) {
stbi_write_jpg_to_func(stb_write_callback, left, CAM_WIDTH, CAM_HEIGHT, 3,
rgb_buffer, JPEG_QUALITY);
}
// Convert right to RGB
yuv422_to_rgb24(cam_buffer_right, rgb_buffer, CAM_WIDTH, CAM_HEIGHT);
if (right) {
stbi_write_jpg_to_func(stb_write_callback, right, CAM_WIDTH, CAM_HEIGHT, 3,
rgb_buffer, JPEG_QUALITY);
}
break;
}
}
return 0;
}
int sendall(int sock, const void* buf, size_t len) {
const uint8_t* ptr = buf;
size_t sent = 0;
while (sent < len) {
int n = send(sock, ptr + sent, len - sent, 0);
if (n < 0) return -1;
if (n == 0) return -2; // Connection closed
sent += n;
}
return 0;
}
Result send_frame_mono(int sock, JpegBuffer* jpeg) {
uint8_t header[6];
// Header: [flags][version][length]
header[0] = 0x00; // No stereo flag
header[1] = PROTO_VERSION;
uint32_t len = htonl(jpeg->size);
memcpy(&header[2], &len, 4);
if (sendall(sock, header, 6) < 0) return -1;
if (sendall(sock, jpeg->data, jpeg->size) < 0) return -1;
return 0;
}
Result send_frame_stereo(int sock, JpegBuffer* left, JpegBuffer* right,
uint16_t wL, uint16_t hL, uint16_t wR, uint16_t hR) {
uint8_t header[18];
// Header: [flags][version][wL][hL][wR][hR][left_len][right_len]
header[0] = PROTO_FLAG_STEREO;
header[1] = PROTO_VERSION;
uint16_t wL_be = htons(wL);
uint16_t hL_be = htons(hL);
uint16_t wR_be = htons(wR);
uint16_t hR_be = htons(hR);
memcpy(&header[2], &wL_be, 2);
memcpy(&header[4], &hL_be, 2);
memcpy(&header[6], &wR_be, 2);
memcpy(&header[8], &hR_be, 2);
uint32_t left_len = htonl(left->size);
uint32_t right_len = htonl(right->size);
memcpy(&header[10], &left_len, 4);
memcpy(&header[14], &right_len, 4);
if (sendall(sock, header, 18) < 0) return -1;
if (sendall(sock, left->data, left->size) < 0) return -1;
if (sendall(sock, right->data, right->size) < 0) return -1;
return 0;
}
+69
View File
@@ -0,0 +1,69 @@
#ifndef CAMERA_STREAM_H
#define CAMERA_STREAM_H
#include <3ds.h>
#include <stdint.h>
#include <stdbool.h>
// Camera configuration
#define CAM_WIDTH 400
#define CAM_HEIGHT 240
#define CAM_BUFFER_SIZE (CAM_WIDTH * CAM_HEIGHT * 2)
#define RGB_BUFFER_SIZE (CAM_WIDTH * CAM_HEIGHT * 3)
// JPEG quality (0-100)
#define JPEG_QUALITY 65
// Protocol constants
#define PROTO_VERSION 0x01
#define PROTO_FLAG_STEREO 0x01
// Camera mode
typedef enum {
CAM_MODE_INNER,
CAM_MODE_OUTER_MONO,
CAM_MODE_OUTER_STEREO
} CameraMode;
// JPEG encoded buffer
typedef struct {
uint8_t* data;
size_t size;
size_t capacity;
} JpegBuffer;
// Initialize camera subsystem
Result camera_init(void);
// Shutdown camera subsystem
void camera_shutdown(void);
// Start camera capture
Result camera_start(CameraMode mode);
// Stop camera capture
void camera_stop(void);
// Capture and encode frame(s)
Result camera_capture_frame(CameraMode mode, JpegBuffer* left, JpegBuffer* right);
// Send mono frame over socket
Result send_frame_mono(int sock, JpegBuffer* jpeg);
// Send stereo frame over socket
Result send_frame_stereo(int sock, JpegBuffer* left, JpegBuffer* right,
uint16_t wL, uint16_t hL, uint16_t wR, uint16_t hR);
// Helper: convert YUV422 to RGB24
void yuv422_to_rgb24(const uint16_t* yuv, uint8_t* rgb, int width, int height);
// Helper: send all data
int sendall(int sock, const void* buf, size_t len);
// Helper: allocate JPEG buffer
JpegBuffer* jpeg_buffer_create(size_t initial_capacity);
// Helper: free JPEG buffer
void jpeg_buffer_free(JpegBuffer* buf);
#endif // CAMERA_STREAM_H
+560
View File
@@ -0,0 +1,560 @@
#include <3ds.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <malloc.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <errno.h>
#include <time.h>
#include "camera_stream.h"
#define SOC_BUFFER_SIZE 0x100000
#define SERVER_PORT 9000
#define RECONNECT_DELAY_MS 3000
#define CONFIG_PATH "/3ds/3ds-webcam-bridge/config.txt"
// Application state
typedef struct {
char server_ip[32];
int server_port;
CameraMode camera_mode;
bool stereo_enabled;
bool streaming;
bool should_exit;
// Stats
u64 frames_sent;
u64 bytes_sent;
float fps;
bool connected;
// Threading
Thread stream_thread;
LightLock state_lock;
} AppState;
static AppState g_state;
static u32* soc_buffer = NULL;
static PrintConsole topScreen, bottomScreen;
// Forward declarations
static void stream_thread_func(void* arg);
static void draw_ui(void);
static void handle_input(void);
static void load_config(void);
static void save_config(void);
static bool input_ip_address(void);
static bool input_port(void);
int main(int argc, char** argv) {
Result ret;
// Initialize services
gfxInitDefault();
consoleInit(GFX_TOP, &topScreen);
consoleInit(GFX_BOTTOM, &bottomScreen);
// Initialize SOC (sockets)
soc_buffer = memalign(0x1000, SOC_BUFFER_SIZE);
if (!soc_buffer) {
printf("Failed to allocate SOC buffer\n");
goto cleanup;
}
ret = socInit(soc_buffer, SOC_BUFFER_SIZE);
if (R_FAILED(ret)) {
printf("socInit failed: 0x%08lX\n", ret);
goto cleanup;
}
// Initialize camera
ret = camera_init();
if (R_FAILED(ret)) {
printf("camera_init failed: 0x%08lX\n", ret);
goto cleanup;
}
// Initialize application state
memset(&g_state, 0, sizeof(AppState));
strcpy(g_state.server_ip, "192.168.1.100"); // Default IP
g_state.server_port = SERVER_PORT;
g_state.camera_mode = CAM_MODE_OUTER_STEREO;
g_state.stereo_enabled = true;
g_state.streaming = false;
g_state.should_exit = false;
LightLock_Init(&g_state.state_lock);
// Load config from SD card
load_config();
printf("3DS Webcam Bridge v1.0\n");
printf("======================\n\n");
printf("Press START to exit\n");
printf("Press A to toggle streaming\n");
printf("Press SELECT to change camera\n");
printf("Press Y to edit IP/Port\n");
printf("Press X to toggle 3D\n\n");
// Main loop
while (aptMainLoop() && !g_state.should_exit) {
handle_input();
draw_ui();
gspWaitForVBlank();
}
// Cleanup
if (g_state.streaming) {
g_state.should_exit = true;
threadJoin(g_state.stream_thread, U64_MAX);
threadFree(g_state.stream_thread);
}
cleanup:
camera_shutdown();
socExit();
if (soc_buffer) {
free(soc_buffer);
}
gfxExit();
return 0;
}
static void handle_input(void) {
hidScanInput();
u32 kDown = hidKeysDown();
// Exit
if (kDown & KEY_START) {
g_state.should_exit = true;
return;
}
// Toggle streaming
if (kDown & KEY_A) {
LightLock_Lock(&g_state.state_lock);
if (g_state.streaming) {
g_state.should_exit = true;
LightLock_Unlock(&g_state.state_lock);
threadJoin(g_state.stream_thread, U64_MAX);
threadFree(g_state.stream_thread);
LightLock_Lock(&g_state.state_lock);
g_state.streaming = false;
g_state.should_exit = false;
g_state.connected = false;
} else {
// Determine camera mode
if (g_state.camera_mode == CAM_MODE_INNER) {
// Inner camera - always mono
g_state.camera_mode = CAM_MODE_INNER;
} else {
// Outer cameras - check stereo flag
if (g_state.stereo_enabled) {
g_state.camera_mode = CAM_MODE_OUTER_STEREO;
} else {
g_state.camera_mode = CAM_MODE_OUTER_MONO;
}
}
g_state.streaming = true;
g_state.frames_sent = 0;
g_state.bytes_sent = 0;
s32 prio = 0;
svcGetThreadPriority(&prio, CUR_THREAD_HANDLE);
g_state.stream_thread = threadCreate(stream_thread_func, NULL, 8*1024, prio-1, -2, false);
}
LightLock_Unlock(&g_state.state_lock);
}
// Change camera mode
if (kDown & KEY_SELECT) {
LightLock_Lock(&g_state.state_lock);
if (!g_state.streaming) {
if (g_state.camera_mode == CAM_MODE_INNER ||
g_state.camera_mode == CAM_MODE_OUTER_MONO ||
g_state.camera_mode == CAM_MODE_OUTER_STEREO) {
// Toggle between inner and outer
if (g_state.camera_mode == CAM_MODE_INNER) {
g_state.camera_mode = g_state.stereo_enabled ?
CAM_MODE_OUTER_STEREO : CAM_MODE_OUTER_MONO;
} else {
g_state.camera_mode = CAM_MODE_INNER;
g_state.stereo_enabled = false; // Inner doesn't support stereo
}
}
}
LightLock_Unlock(&g_state.state_lock);
}
// Toggle stereo (only for outer cameras)
if (kDown & KEY_X) {
LightLock_Lock(&g_state.state_lock);
if (!g_state.streaming &&
(g_state.camera_mode == CAM_MODE_OUTER_MONO ||
g_state.camera_mode == CAM_MODE_OUTER_STEREO)) {
g_state.stereo_enabled = !g_state.stereo_enabled;
}
LightLock_Unlock(&g_state.state_lock);
}
// Edit IP/Port (only when not streaming)
if (kDown & KEY_Y) {
LightLock_Lock(&g_state.state_lock);
if (!g_state.streaming) {
LightLock_Unlock(&g_state.state_lock);
// Show menu on bottom screen
consoleSelect(&bottomScreen);
printf("\x1b[2J");
printf("\x1b[0;0H");
printf("=== Configuration ===\n\n");
printf("A: Edit IP Address\n");
printf("B: Edit Port\n");
printf("X: Cancel\n\n");
printf("Current:\n");
printf("IP: %s\n", g_state.server_ip);
printf("Port: %d\n", g_state.server_port);
gfxFlushBuffers();
gfxSwapBuffers();
// Wait for choice
while (aptMainLoop()) {
hidScanInput();
u32 kPressed = hidKeysDown();
if (kPressed & KEY_A) {
if (input_ip_address()) {
save_config();
}
break;
} else if (kPressed & KEY_B) {
if (input_port()) {
save_config();
}
break;
} else if (kPressed & KEY_X) {
break;
}
gspWaitForVBlank();
}
} else {
LightLock_Unlock(&g_state.state_lock);
}
}
}
static void draw_ui(void) {
LightLock_Lock(&g_state.state_lock);
// Top screen - Status
consoleSelect(&topScreen);
printf("\x1b[2J"); // Clear screen
printf("\x1b[0;0H"); // Home cursor
printf("=== 3DS Webcam Bridge ===\n\n");
printf("Server: %s:%d\n", g_state.server_ip, g_state.server_port);
printf("Status: %s\n", g_state.streaming ?
(g_state.connected ? "STREAMING" : "CONNECTING...") : "STOPPED");
const char* cam_str = "Unknown";
switch (g_state.camera_mode) {
case CAM_MODE_INNER:
cam_str = "Inner (Mono)";
break;
case CAM_MODE_OUTER_MONO:
cam_str = "Outer (Mono)";
break;
case CAM_MODE_OUTER_STEREO:
cam_str = "Outer (Stereo)";
break;
}
printf("Camera: %s\n", cam_str);
printf("3D Mode: %s\n", g_state.stereo_enabled ? "Enabled" : "Disabled");
printf("\n--- Statistics ---\n");
printf("Frames: %llu\n", g_state.frames_sent);
printf("Data: %.2f MB\n", g_state.bytes_sent / (1024.0 * 1024.0));
printf("FPS: %.1f\n", g_state.fps);
printf("\n--- Controls ---\n");
printf("A: Start/Stop\n");
printf("SELECT: Change Camera\n");
printf("X: Toggle 3D%s\n",
(g_state.camera_mode == CAM_MODE_INNER) ? " (Outer only!)" : "");
printf("Y: Edit IP/Port\n");
printf("START: Exit\n");
// Bottom screen - Settings
consoleSelect(&bottomScreen);
printf("\x1b[2J");
printf("\x1b[0;0H");
printf("=== Settings ===\n\n");
printf("Network Configuration:\n");
printf("IP: %s\n", g_state.server_ip);
printf("Port: %d\n", g_state.server_port);
printf("-> Press Y to edit\n");
printf("\nCamera Selection:\n");
printf("%s Inner (single lens)\n",
(g_state.camera_mode == CAM_MODE_INNER) ? ">" : " ");
printf("%s Outer (dual lens)\n",
(g_state.camera_mode != CAM_MODE_INNER) ? ">" : " ");
printf("\n3D Stereo Mode:\n");
if (g_state.camera_mode == CAM_MODE_INNER) {
printf(" (Requires outer cameras)\n");
} else {
printf("[%c] Enable 3D (stereo)\n", g_state.stereo_enabled ? 'X' : ' ');
printf("Press X to toggle\n");
}
printf("\nResolution: %dx%d\n", CAM_WIDTH, CAM_HEIGHT);
printf("JPEG Quality: %d\n", JPEG_QUALITY);
LightLock_Unlock(&g_state.state_lock);
gfxFlushBuffers();
gfxSwapBuffers();
}
static void stream_thread_func(void* arg) {
(void)arg;
int sock = -1;
JpegBuffer* jpeg_left = NULL;
JpegBuffer* jpeg_right = NULL;
u64 last_time = 0;
u32 frame_count = 0;
jpeg_left = jpeg_buffer_create(CAM_WIDTH * CAM_HEIGHT);
jpeg_right = jpeg_buffer_create(CAM_WIDTH * CAM_HEIGHT);
if (!jpeg_left || !jpeg_right) {
goto thread_cleanup;
}
while (!g_state.should_exit) {
// Connect to server
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
svcSleepThread(RECONNECT_DELAY_MS * 1000000ULL);
continue;
}
// Set socket options
int flag = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(g_state.server_port);
inet_aton(g_state.server_ip, &server_addr.sin_addr);
if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
close(sock);
sock = -1;
svcSleepThread(RECONNECT_DELAY_MS * 1000000ULL);
continue;
}
// Connected
LightLock_Lock(&g_state.state_lock);
g_state.connected = true;
CameraMode mode = g_state.camera_mode;
LightLock_Unlock(&g_state.state_lock);
// Start camera
if (R_FAILED(camera_start(mode))) {
close(sock);
sock = -1;
continue;
}
// Streaming loop
last_time = svcGetSystemTick();
frame_count = 0;
while (!g_state.should_exit) {
// Capture frame
Result ret = camera_capture_frame(mode, jpeg_left,
(mode == CAM_MODE_OUTER_STEREO) ? jpeg_right : NULL);
if (R_FAILED(ret)) {
break;
}
// Send frame
if (mode == CAM_MODE_OUTER_STEREO) {
ret = send_frame_stereo(sock, jpeg_left, jpeg_right,
CAM_WIDTH, CAM_HEIGHT, CAM_WIDTH, CAM_HEIGHT);
} else {
ret = send_frame_mono(sock, jpeg_left);
}
if (R_FAILED(ret)) {
break; // Connection lost
}
// Update stats
LightLock_Lock(&g_state.state_lock);
g_state.frames_sent++;
g_state.bytes_sent += jpeg_left->size;
if (mode == CAM_MODE_OUTER_STEREO) {
g_state.bytes_sent += jpeg_right->size;
}
frame_count++;
u64 current_time = svcGetSystemTick();
u64 elapsed_ms = (current_time - last_time) * 1000 / CPU_TICKS_PER_MSEC;
if (elapsed_ms >= 1000) {
g_state.fps = (float)frame_count * 1000.0f / elapsed_ms;
frame_count = 0;
last_time = current_time;
}
LightLock_Unlock(&g_state.state_lock);
// Frame rate limiting (target ~15 FPS)
svcSleepThread(33 * 1000000ULL); // ~30ms
}
// Cleanup connection
camera_stop();
close(sock);
sock = -1;
LightLock_Lock(&g_state.state_lock);
g_state.connected = false;
LightLock_Unlock(&g_state.state_lock);
if (g_state.should_exit) break;
// Wait before reconnecting
svcSleepThread(RECONNECT_DELAY_MS * 1000000ULL);
}
thread_cleanup:
if (sock >= 0) {
close(sock);
}
if (jpeg_left) {
jpeg_buffer_free(jpeg_left);
}
if (jpeg_right) {
jpeg_buffer_free(jpeg_right);
}
}
// Configuration file loading
static void load_config(void) {
FILE* f = fopen(CONFIG_PATH, "r");
if (!f) {
return; // Use defaults if file doesn't exist
}
char line[128];
while (fgets(line, sizeof(line), f)) {
// Remove newline
line[strcspn(line, "\r\n")] = 0;
// Parse key=value
char* equals = strchr(line, '=');
if (!equals) continue;
*equals = '\0';
char* key = line;
char* value = equals + 1;
if (strcmp(key, "ip") == 0) {
strncpy(g_state.server_ip, value, sizeof(g_state.server_ip) - 1);
g_state.server_ip[sizeof(g_state.server_ip) - 1] = '\0';
} else if (strcmp(key, "port") == 0) {
int port = atoi(value);
if (port > 0 && port < 65536) {
g_state.server_port = port;
}
}
}
fclose(f);
}
// Configuration file saving
static void save_config(void) {
// Create directory if it doesn't exist
mkdir("/3ds", 0777);
mkdir("/3ds/3ds-webcam-bridge", 0777);
FILE* f = fopen(CONFIG_PATH, "w");
if (!f) {
return;
}
fprintf(f, "ip=%s\n", g_state.server_ip);
fprintf(f, "port=%d\n", g_state.server_port);
fclose(f);
}
// Input IP address using software keyboard
static bool input_ip_address(void) {
SwkbdState swkbd;
char mybuf[32];
SwkbdButton button = SWKBD_BUTTON_NONE;
swkbdInit(&swkbd, SWKBD_TYPE_NORMAL, 2, -1);
swkbdSetHintText(&swkbd, "Enter IP address");
swkbdSetInitialText(&swkbd, g_state.server_ip);
swkbdSetFeatures(&swkbd, SWKBD_MULTILINE);
button = swkbdInputText(&swkbd, mybuf, sizeof(mybuf));
if (button == SWKBD_BUTTON_CONFIRM) {
// Basic validation
if (strlen(mybuf) > 0) {
strncpy(g_state.server_ip, mybuf, sizeof(g_state.server_ip) - 1);
g_state.server_ip[sizeof(g_state.server_ip) - 1] = '\0';
return true;
}
}
return false;
}
// Input port using software keyboard
static bool input_port(void) {
SwkbdState swkbd;
char mybuf[16];
char portstr[16];
SwkbdButton button = SWKBD_BUTTON_NONE;
snprintf(portstr, sizeof(portstr), "%d", g_state.server_port);
swkbdInit(&swkbd, SWKBD_TYPE_NUMPAD, 2, -1);
swkbdSetHintText(&swkbd, "Enter port (1-65535)");
swkbdSetInitialText(&swkbd, portstr);
swkbdSetFeatures(&swkbd, SWKBD_FIXED_WIDTH);
button = swkbdInputText(&swkbd, mybuf, sizeof(mybuf));
if (button == SWKBD_BUTTON_CONFIRM) {
int port = atoi(mybuf);
if (port > 0 && port < 65536) {
g_state.server_port = port;
return true;
}
}
return false;
}
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
# 3DS Webcam Bridge
Stream your Nintendo 3DS cameras to your PC as a virtual webcam! Support for both **mono** and **stereo (3D)** modes using the outer dual cameras.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Platform](https://img.shields.io/badge/platform-3DS%20%7C%20Windows%20%7C%20Linux-lightgrey)
## Features
- **📷 Multiple Camera Modes**
- Inner camera (single lens, mono)
- Outer cameras (dual lens, mono or stereo)
- True 3D support with synchronized stereo capture
- **🎥 Stereo Output Modes**
- **Off**: Left eye only
- **Side-by-Side**: Horizontal stereo for 3D displays/VR
- **Anaglyph**: Red-cyan 3D glasses
- **Dual**: Separate left/right virtual cameras
- **💻 Cross-Platform PC Support**
- **Windows 10/11**: Via OBS Virtual Camera
- **Linux**: Via v4l2loopback + pyvirtualcam/pyfakewebcam/ffmpeg
- **⚡ Real-time Streaming**
- ~15 FPS @ 400×240 per camera
- JPEG compression for efficient Wi-Fi streaming
- Auto-reconnect on connection loss
---
## 📋 Table of Contents
- [Security & Ethics Notice](#security--ethics-notice)
- [Quick Start](#quick-start)
- [Linux (Ubuntu/Debian)](#linux-ubuntudebian)
- [Windows 10/11](#windows-1011)
- [Building for 3DS](#building-for-3ds)
- [Usage](#usage)
- [3DS Application](#3ds-application)
- [PC Server Options](#pc-server-options)
- [Project Structure](#project-structure)
- [Requirements](#requirements)
- [Troubleshooting](#troubleshooting)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [License](#license)
---
## ⚠️ Security & Ethics Notice
**IMPORTANT**: This software is intended for personal, educational, and creative use only.
- **Privacy**: Streaming camera feeds creates privacy risks. Always obtain consent from anyone who may be captured by the cameras.
- **Network Security**: The stream is **not encrypted** by default. Only use on trusted networks.
- **Responsible Use**: Do not use this software for surveillance, harassment, or any illegal activities.
- **Children**: If used by or around minors, ensure appropriate adult supervision and consent.
By using this software, you accept full responsibility for its use and agree to comply with all applicable laws and regulations.
---
## 🚀 Quick Start
### Linux (Ubuntu/Debian)
#### 1. Install PC Server Dependencies
```bash
# Clone or navigate to the repository
cd 3ds-webcam-bridge
# Install dependencies (Python, v4l2loopback, ffmpeg, etc.)
./scripts/pc-install.sh
# Create virtual camera device
./scripts/create-v4l2.sh
# For dual devices (stereo mode):
./scripts/create-v4l2.sh --dual
```
#### 2. Run the Server
```bash
# Mono mode (default)
./pc-server/run_server.sh --device /dev/video0
# Side-by-side stereo
./pc-server/run_server.sh --stereo-mode sbs --width 400 --height 240 --device /dev/video0
# Dual device stereo
./pc-server/run_server.sh --stereo-mode dual --dual-devices /dev/video0,/dev/video1
```
#### 3. Use in Applications
Open any application that supports webcams (Zoom, OBS, Chrome, etc.) and select:
- **Single mode**: `/dev/video0` (or "3DS Webcam")
- **Dual mode**: `/dev/video0` (Left) and `/dev/video1` (Right)
---
### Windows 10/11
#### 1. Install PC Server Dependencies
**Run PowerShell as Administrator:**
```powershell
# Allow script execution for this session
Set-ExecutionPolicy Bypass -Scope Process -Force
# Navigate to repository
cd 3ds-webcam-bridge
# Run installer (installs Python, OBS Studio, dependencies)
.\scripts\windows\pc-install.ps1 -Port 9000
```
The installer will:
- ✅ Install Python 3.11 (if needed)
- ✅ Install OBS Studio with Virtual Camera
- ✅ Create Python virtual environment
- ✅ Install Python packages
- ✅ Add firewall rule for TCP port 9000
#### 2. Initialize OBS Virtual Camera
**Important first-time step:**
1. Launch **OBS Studio**
2. Go to **Tools → VirtualCam → Start**
3. Close OBS (the virtual camera will remain available)
#### 3. Run the Server
```powershell
# Mono mode
.\pc-server\run_server.ps1 -Port 9000 -Device "OBS Virtual Camera"
# Side-by-side stereo
.\pc-server\run_server.ps1 -StereoMode sbs -Width 400 -Height 240 -Device "OBS Virtual Camera"
# Dual device stereo (requires multiple OBS virtual cameras)
.\pc-server\run_server.ps1 -StereoMode dual -DualDevices "OBS Virtual Camera,OBS Virtual Camera 2"
```
#### 4. Use in Applications
Open any application (Zoom, Discord, Chrome, etc.) and select:
- **"OBS Virtual Camera"** as your camera device
---
### Building for 3DS
#### 1. Install devkitPro (Linux)
```bash
# Install devkitPro toolchain
./scripts/install-devkitpro.sh
# Set environment variables (add to ~/.bashrc for persistence)
export DEVKITPRO=/opt/devkitpro
export DEVKITARM=$DEVKITPRO/devkitARM
export PATH=$DEVKITARM/bin:$PATH
# Reload environment
source ~/.bashrc
```
#### 2. Build the 3DS App
```bash
# Build (downloads stb_image_write.h if needed)
./scripts/build-3ds.sh
# Output: dist/3ds-webcam-bridge.3dsx
```
#### 3. Install on 3DS
1. Copy `dist/3ds-webcam-bridge.3dsx` to your SD card:
```
SD:/3ds/3ds-webcam-bridge/3ds-webcam-bridge.3dsx
```
2. Insert SD card into 3DS
3. Launch **Homebrew Launcher**
4. Launch **3DS Webcam Bridge**
---
## 📖 Usage
### 3DS Application
#### Controls
| Button | Action |
|--------|--------|
| **A** | Start/Stop streaming |
| **SELECT** | Toggle between Inner/Outer cameras |
| **X** | Toggle 3D mode (outer cameras only) |
| **Y** | Edit IP address and port |
| **START** | Exit application |
#### UI Screens
**Top Screen**: Status display
- Server IP and port
- Connection status
- Camera mode
- Frame count, data sent, FPS
**Bottom Screen**: Settings
- **Network configuration** (IP/Port) - Press Y to edit
- Camera selection (Inner/Outer)
- 3D checkbox (enabled only for Outer cameras)
- Resolution and quality info
#### Camera Modes
| Mode | Description | 3D Support |
|------|-------------|------------|
| **Inner** | Front-facing camera (single lens) | ❌ No |
| **Outer** | Rear cameras (dual lens) | ✅ Yes |
**Important**: The **3D checkbox** is only enabled when **Outer** cameras are selected. The inner camera has only one lens and cannot provide stereo 3D.
#### Configuration
**Method 1: Using Software Keyboard (Recommended)**
1. Press **Y** button in the app
2. Select **A** to edit IP address or **B** to edit port
3. Use the 3DS keyboard to enter your PC's IP address
4. Configuration is automatically saved to SD card
**Method 2: Manual Config File**
Create a config file at **SD:/3ds/3ds-webcam-bridge/config.txt**:
```
# Your PC's IP address
ip=192.168.1.100
# Server port
port=9000
```
The app will load this config on startup. Any changes made via the Y button will update this file.
---
### PC Server Options
#### Full Command-Line Reference
```bash
server.py [options]
Options:
--listen ADDRESS Listen address (default: 0.0.0.0)
--port PORT TCP port (default: 9000)
--mode MODE Force mono or auto-detect (default: auto)
--stereo-mode MODE Stereo output: off, sbs, anaglyph, dual (default: off)
--device DEVICE Virtual camera device
Linux: /dev/videoX
Windows: "OBS Virtual Camera" or index
--dual-devices DEVICES Two devices for dual mode (comma-separated)
--width WIDTH Output width per eye (default: 640)
--height HEIGHT Output height (default: 480)
--fps FPS Output frame rate (default: 15)
--prefer-backend BACKEND Backend: pyvirtualcam, pyfakewebcam, ffmpeg
--verbose Verbose logging
```
#### Examples
**Linux**:
```bash
# Basic mono
./run_server.sh --device /dev/video0
# Side-by-side stereo (1280x480 output)
./run_server.sh --stereo-mode sbs --width 640 --height 480
# Anaglyph 3D (red-cyan)
./run_server.sh --stereo-mode anaglyph --width 640 --height 480
# Dual outputs
./run_server.sh --stereo-mode dual --dual-devices /dev/video0,/dev/video1
# Use pyfakewebcam backend
./run_server.sh --prefer-backend pyfakewebcam --device /dev/video0
```
**Windows**:
```powershell
# Basic mono
.\run_server.ps1 -Device "OBS Virtual Camera"
# Side-by-side stereo
.\run_server.ps1 -StereoMode sbs -Width 640 -Height 480
# Dual outputs (requires configuring multiple OBS virtual cameras)
.\run_server.ps1 -StereoMode dual -DualDevices "OBS Virtual Camera,OBS Virtual Camera 2"
```
---
## 📁 Project Structure
```
3ds-webcam-bridge/
├── README.md # This file
├── LICENSE # MIT license
├── .gitignore
├── examples/
│ └── sample-config.json # Example configuration
├── 3ds-app/ # 3DS homebrew application
│ ├── Makefile
│ └── source/
│ ├── main.c # Main app with UI
│ ├── camera_stream.c # Camera capture & streaming
│ ├── camera_stream.h
│ └── third_party/
│ └── stb_image_write.h # JPEG encoder
├── pc-server/ # PC server (Python)
│ ├── server.py # Main server application
│ ├── requirements.txt
│ ├── run_server.sh # Linux launcher
│ └── run_server.ps1 # Windows launcher
├── scripts/ # Installation & build scripts
│ ├── install-devkitpro.sh # Install 3DS toolchain (Linux)
│ ├── build-3ds.sh # Build 3DS app
│ ├── pc-install.sh # Install PC dependencies (Linux)
│ ├── create-v4l2.sh # Create v4l2loopback devices (Linux)
│ └── windows/
│ ├── pc-install.ps1 # Install PC dependencies (Windows)
│ └── helpers.psm1 # PowerShell helper functions
├── docs/ # Documentation
│ ├── design.md # Technical design details
│ └── troubleshooting.md # Common issues and solutions
└── dist/ # Build output
└── 3ds-webcam-bridge.3dsx
```
---
## 📦 Requirements
### 3DS
- **Hardware**: Nintendo 3DS/2DS (Old or New) with Homebrew Launcher
- **Firmware**: Any version with Homebrew access
- **SD Card**: For installing the `.3dsx` file
- **Wi-Fi**: 2.4 GHz network (3DS doesn't support 5 GHz)
### PC - Linux
- **OS**: Ubuntu 20.04+, Debian 11+, or equivalent
- **Kernel**: 4.4+ with v4l2loopback support
- **Python**: 3.8+
- **Packages**:
- `v4l2loopback-dkms`
- `v4l2loopback-utils`
- `ffmpeg`
- `python3-venv`
### PC - Windows
- **OS**: Windows 10 (1809+) or Windows 11
- **Python**: 3.8+ (auto-installed by script)
- **OBS Studio**: 26.0+ (auto-installed by script)
- **winget**: For automated installation
### Network
- **Both devices on same network** (LAN/Wi-Fi)
- **TCP port 9000** open (configurable)
- **Recommended**: 5 GHz Wi-Fi for PC (3DS uses 2.4 GHz)
---
## 🔧 Troubleshooting
### Common Issues
#### "No virtual camera found" (Windows)
- **Solution**: Start OBS Studio, go to **Tools → VirtualCam → Start**, then try again
- Ensure OBS Studio 26.0+ is installed (check via `winget list OBSProject.OBSStudio`)
#### "/dev/video0: Device or resource busy" (Linux)
- **Solution**: Close all applications using the device (browser, Zoom, etc.)
- Check: `sudo fuser /dev/video0`
- Kill processes: `sudo fuser -k /dev/video0`
#### "Connection refused" / "Timeout"
- Check that both devices are on the same network
- Verify the server IP address on 3DS matches your PC
- Find your PC IP:
- **Linux**: `ip addr show` or `hostname -I`
- **Windows**: `ipconfig` (look for IPv4)
- Check firewall:
- **Linux**: `sudo ufw allow 9000/tcp`
- **Windows**: Run installer as Administrator to add rule
#### Low FPS / Laggy stream
- Reduce resolution: `--width 320 --height 240`
- Lower JPEG quality (edit `3ds-app/source/camera_stream.h`, change `JPEG_QUALITY`)
- Use 5 GHz Wi-Fi for PC (3DS is limited to 2.4 GHz)
- Ensure 3DS is close to Wi-Fi router
#### "v4l2loopback not loaded" (Linux)
- **Solution**: Run `./scripts/create-v4l2.sh`
- Manual: `sudo modprobe v4l2loopback`
- Check: `lsmod | grep v4l2loopback`
#### Stereo mode not working
- Ensure 3DS is set to **Outer** cameras with **3D enabled**
- Verify server is using `--stereo-mode sbs` (or anaglyph/dual)
- Check logs with `--verbose` flag
For more detailed troubleshooting, see [`docs/troubleshooting.md`](docs/troubleshooting.md).
---
## 📚 Documentation
- **[design.md](docs/design.md)**: Protocol specification, architecture, performance tuning
- **[troubleshooting.md](docs/troubleshooting.md)**: Detailed problem-solving guide
- **[sample-config.json](examples/sample-config.json)**: Example configuration
### External Resources
- **devkitPro**: https://devkitpro.org/wiki/Getting_Started
- **libctru Camera API**: https://libctru.devkitpro.org/cam_8h.html
- **pyvirtualcam**: https://github.com/letmaik/pyvirtualcam
- **v4l2loopback**: https://github.com/umlaeute/v4l2loopback
- **stb_image_write**: https://github.com/nothings/stb
---
## 🤝 Contributing
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test on both platforms (if possible)
5. Submit a pull request
### Ideas for Contributions
- macOS support
- Config UI for IP entry on 3DS
- Recording to file option
- H.264 encoding for lower bandwidth
- Portrait/landscape rotation
- Multiple 3DS clients simultaneously
---
## 📄 License
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
### Third-Party Libraries
- **libctru**: https://github.com/devkitPro/libctru (Zlib license)
- **stb_image_write**: https://github.com/nothings/stb (Public domain / MIT)
- **pyvirtualcam**: https://github.com/letmaik/pyvirtualcam (GPL-2.0)
---
## 🙏 Acknowledgments
- devkitPro team for the amazing 3DS toolchain
- stb libraries by Sean Barrett
- v4l2loopback developers
- pyvirtualcam and OBS Studio teams
---
## ⚡ Performance Tips
### 3DS Side
- Keep 3DS close to Wi-Fi router
- Close other applications/homebrew
- Use lower resolution if needed (edit source)
### PC Side
- Use wired Ethernet for PC (3DS will be on Wi-Fi)
- Close unnecessary applications
- For dual mode, ensure both v4l2 devices are created
### Network
- Use QoS to prioritize 3DS traffic if possible
- Avoid network congestion (large downloads, streaming)
---
**Made with ❤️ for the 3DS homebrew community**
+479
View File
@@ -0,0 +1,479 @@
# Technical Design Document
## Architecture Overview
The 3DS Webcam Bridge consists of two main components:
1. **3DS Homebrew Application**: Captures camera frames, encodes to JPEG, and streams over TCP
2. **PC Server**: Receives frames, decodes, and exposes as virtual webcam(s)
```
┌─────────────────┐ ┌──────────────────┐
│ Nintendo 3DS │ │ PC (Server) │
│ │ │ │
│ ┌───────────┐ │ │ ┌────────────┐ │
│ │ Camera │ │ TCP/IP (Wi-Fi) │ │ Server │ │
│ │ Capture │ │───────────────────► │ │ (Python) │ │
│ └─────┬─────┘ │ Port 9000 │ └──────┬─────┘ │
│ │ │ │ │ │
│ ┌─────▼─────┐ │ │ ┌──────▼─────┐ │
│ │ JPEG │ │ │ │ JPEG │ │
│ │ Encoder │ │ │ │ Decoder │ │
│ └─────┬─────┘ │ │ └──────┬─────┘ │
│ │ │ │ │ │
│ ┌─────▼─────┐ │ │ ┌──────▼─────┐ │
│ │ Network │ │ │ │ Virtual │ │
│ │ Protocol │ │ │ │ Webcam │ │
│ └───────────┘ │ │ └────────────┘ │
└─────────────────┘ └──────────────────┘
```
---
## Protocol Specification
### Wire Protocol
The protocol is designed to be backward-compatible and support both mono and stereo streams.
#### Header Format
All frames begin with a 2-byte header:
```
[FLAGS (1 byte)][VERSION (1 byte)]
```
**FLAGS**:
- Bit 0: Stereo flag (1 = stereo, 0 = mono)
- Bits 1-7: Reserved (must be 0)
**VERSION**: Protocol version (currently 0x01)
#### Mono Frame Format
```
┌─────────┬─────────┬────────────┬──────────────┐
│ FLAGS │ VERSION │ LENGTH │ JPEG DATA │
│ (1 byte)│ (1 byte)│ (4 bytes) │ (LENGTH) │
│ 0x00 │ 0x01 │ Big-endian │ │
└─────────┴─────────┴────────────┴──────────────┘
```
**Total header**: 6 bytes
- FLAGS: 0x00 (no stereo)
- VERSION: 0x01
- LENGTH: 32-bit unsigned big-endian integer (JPEG size)
#### Stereo Frame Format
```
┌─────────┬─────────┬────────────────────────────────┬─────────────┬──────────────┬─────────────┬──────────────┐
│ FLAGS │ VERSION │ DIMENSIONS │ LEFT_LEN │ RIGHT_LEN │ LEFT_JPEG │ RIGHT_JPEG │
│ (1 byte)│ (1 byte)│ (8 bytes) │ (4 bytes) │ (4 bytes) │ (LEFT_LEN) │ (RIGHT_LEN) │
│ 0x01 │ 0x01 │ wL,hL,wR,hR (16-bit BE each) │ Big-endian │ Big-endian │ │ │
└─────────┴─────────┴────────────────────────────────┴─────────────┴──────────────┴─────────────┴──────────────┘
```
**Total header**: 18 bytes
- FLAGS: 0x01 (stereo enabled)
- VERSION: 0x01
- DIMENSIONS:
- wL: Left width (16-bit BE)
- hL: Left height (16-bit BE)
- wR: Right width (16-bit BE)
- hR: Right height (16-bit BE)
- LEFT_LEN: Left JPEG size (32-bit BE)
- RIGHT_LEN: Right JPEG size (32-bit BE)
### Network Transport
- **Protocol**: TCP
- **Port**: 9000 (default, configurable)
- **Connection**: Client (3DS) → Server (PC)
- **Reconnection**: Automatic with exponential backoff (3s delay)
- **Socket Options**: `TCP_NODELAY` enabled for lower latency
---
## 3DS Implementation Details
### Camera Subsystem
The 3DS has multiple camera configurations:
| Camera | Hardware | Purpose | Stereo? |
|--------|----------|---------|---------|
| **Inner (SELECT_IN1)** | Single lens | Front-facing selfie camera | ❌ No |
| **Outer (SELECT_OUT1, SELECT_OUT2)** | Dual lens | Rear stereo cameras | ✅ Yes |
#### Camera Configuration
```c
CAMU_SetSize(select, SIZE_CTR_TOP_LCD, CONTEXT_A); // 400×240
CAMU_SetOutputFormat(select, OUTPUT_YUV_422, CONTEXT_A); // YUV422
CAMU_SetFrameRate(select, FRAME_RATE_15); // 15 FPS target
CAMU_SetNoiseFilter(select, true); // Reduce noise
CAMU_SetAutoExposure(select, true); // Auto exposure
CAMU_SetAutoWhiteBalance(select, true); // Auto WB
```
#### Capture Process
1. **Select camera(s)**: `SELECT_IN1` or `SELECT_OUT1|SELECT_OUT2`
2. **Set transfer size**: `CAMU_GetMaxBytes()``CAMU_SetTransferBytes()`
3. **Clear buffer**: `CAMU_ClearBuffer()`
4. **Start capture**: `CAMU_StartCapture()`
5. **Set receiving**: `CAMU_SetReceiving()` → returns handle
6. **Wait for completion**: `svcWaitSynchronization(handle)`
7. **Stop capture**: `CAMU_StopCapture()`
For stereo, both cameras are captured sequentially but as quickly as possible to minimize desynchronization.
### Color Space Conversion
3DS outputs **YUV422** format. We convert to **RGB24** for JPEG encoding:
```c
// YUV422 format: [Y0 U Y1 V] packed in 16-bit values
// Conversion matrix:
R = 1.164*(Y - 16) + 1.596*(V - 128)
G = 1.164*(Y - 16) - 0.391*(U - 128) - 0.813*(V - 128)
B = 1.164*(Y - 16) + 2.018*(U - 128)
```
Fixed-point integer math is used for performance:
```c
R = (298*c + 409*e + 128) >> 8
G = (298*c - 100*d - 208*e + 128) >> 8
B = (298*c + 516*d + 128) >> 8
```
Where: `c = Y-16`, `d = U-128`, `e = V-128`
### JPEG Encoding
We use **stb_image_write.h** (public domain, single-header library):
```c
stbi_write_jpg_to_func(callback, context, width, height, 3, rgb_data, quality);
```
- **Quality**: 65 (configurable via `JPEG_QUALITY` define)
- **Typical size**: 10-20 KB per 400×240 frame
- **Encoding time**: ~30-50ms on 3DS hardware
### Threading Model
```
Main Thread:
- UI rendering (top/bottom screens)
- Input handling
- State management
Stream Thread:
- Socket connection/reconnection
- Camera capture loop
- JPEG encoding
- Network transmission
- Statistics tracking
```
Synchronization via `LightLock` for shared state access.
### Performance Characteristics
| Metric | Mono | Stereo |
|--------|------|--------|
| **Capture Time** | ~30ms | ~60ms |
| **Encode Time** | ~40ms | ~80ms |
| **Network Time** | ~20ms | ~40ms |
| **Total Latency** | ~90ms | ~180ms |
| **Target FPS** | 12-15 | 10-12 |
| **Bandwidth** | ~150 KB/s | ~300 KB/s |
*Note: Actual performance varies based on scene complexity, network conditions, and JPEG quality settings.*
---
## PC Server Implementation
### Backend Architecture
The server supports multiple virtual camera backends:
```
┌────────────────────────────────────┐
│ Server Core (server.py) │
│ - Protocol parsing │
│ - Frame decoding (JPEG → RGB) │
│ - Stereo composition │
└──────────────┬─────────────────────┘
┌───────┴────────┐
│ │
┌───▼───┐ ┌────▼────┐ ┌────────┐
│ pyvir │ │ pyfake │ │ ffmpeg │
│tualcam│ │ webcam │ │ pipe │
└───┬───┘ └────┬────┘ └────┬───┘
│ │ │
┌───▼────┐ ┌────▼────┐ ┌────▼───┐
│Windows │ │ Linux │ │ Linux │
│OBS Virt│ │v4l2loop │ │v4l2loop│
└────────┘ └─────────┘ └────────┘
```
### Stereo Modes
#### 1. Off (Mono)
- Send left eye only
- Resolution: `W × H`
- Use case: Standard webcam applications
#### 2. Side-by-Side (SBS)
- Compose horizontally: `[LEFT | RIGHT]`
- Resolution: `2W × H`
- Use case: 3D displays, VR headsets
- Implementation:
```python
sbs = np.concatenate([left_arr, right_arr], axis=1)
```
#### 3. Anaglyph (Red-Cyan)
- Red channel from left, green+blue from right
- Resolution: `W × H`
- Use case: Red-cyan 3D glasses
- Implementation:
```python
anaglyph[:,:,0] = left[:,:,0] # Red from left
anaglyph[:,:,1] = right[:,:,1] # Green from right
anaglyph[:,:,2] = right[:,:,2] # Blue from right
```
#### 4. Dual Devices
- Two separate virtual cameras
- Resolution: `W × H` each
- Use case: Advanced VR setups, custom stereo applications
### Backend Selection Logic
```
User preference: --prefer-backend
├─ pyvirtualcam?
│ ├─ Available? → Use it
│ └─ Not available → Fallback
├─ pyfakewebcam? (Linux only)
│ ├─ Available? → Use it
│ └─ Not available → Fallback
└─ ffmpeg? (Linux only)
├─ Available? → Use it
└─ Not available → Error
```
### Frame Processing Pipeline
```
1. TCP Receive
├─ Read 2-byte header
├─ Parse FLAGS and VERSION
└─ Determine mono/stereo
2. Decode
├─ Mono: Read 4-byte length → JPEG data
└─ Stereo: Read 8-byte dims → 8-byte lengths → 2× JPEG data
3. PIL Decoding
├─ JPEG bytes → PIL Image
└─ Convert to RGB mode
4. Composition (if stereo)
├─ Off: left only
├─ SBS: horizontal concat
├─ Anaglyph: color channel merge
└─ Dual: process separately
5. Resize
├─ Target resolution (--width, --height)
└─ BILINEAR interpolation
6. NumPy Conversion
└─ RGB24 format (uint8, HxWx3)
7. Backend Send
├─ pyvirtualcam: camera.send(frame)
├─ pyfakewebcam: camera.schedule_frame(frame)
└─ ffmpeg: stdin.write(frame.tobytes())
```
---
## Platform-Specific Details
### Windows: OBS Virtual Camera
- **Provider**: OBS Studio 26.0+
- **Driver**: OBS VirtualCam plugin (bundled)
- **Location**: Installed with OBS, typically `C:\Program Files\obs-studio\`
- **Access**: Via pyvirtualcam library
- **Multiple devices**: User must configure in OBS settings
**First-time setup**:
1. Launch OBS Studio
2. Tools → VirtualCam → Start
3. This initializes the driver
4. Close OBS (driver remains active)
**Device naming**:
- Default: `"OBS Virtual Camera"`
- Can be referenced by index: `0`, `1`, etc.
### Linux: v4l2loopback
- **Provider**: Kernel module `v4l2loopback`
- **Installation**: `sudo apt install v4l2loopback-dkms v4l2loopback-utils`
- **Device creation**:
```bash
sudo modprobe v4l2loopback devices=1 video_nr=0 card_label="3DS Webcam" exclusive_caps=1
```
**Persistent setup** (`/etc/modprobe.d/v4l2loopback.conf`):
```
options v4l2loopback devices=1 video_nr=0 card_label="3DS Webcam" exclusive_caps=1
```
**Multiple devices**:
```bash
sudo modprobe v4l2loopback devices=2 video_nr=0,1 \
card_label="3DS Left","3DS Right" exclusive_caps=1,1
```
**Verification**:
```bash
v4l2-ctl --list-devices
ls -l /dev/video*
```
---
## Performance Tuning
### Bandwidth Optimization
**JPEG Quality vs Size**:
| Quality | Avg Size (400×240) | Bandwidth @ 15 FPS |
|---------|--------------------|--------------------|
| 50 | 8 KB | 120 KB/s |
| 65 | 12 KB | 180 KB/s |
| 80 | 18 KB | 270 KB/s |
| 95 | 30 KB | 450 KB/s |
**Recommendation**: Quality 60-70 for good balance.
### Resolution Options
| Resolution | Capture Time | Bandwidth @ Q65 |
|------------|--------------|-----------------|
| 320×240 | ~25ms | ~140 KB/s |
| 400×240 | ~30ms | ~180 KB/s |
| 512×384* | N/A | (Unsupported) |
*Note: 3DS hardware maximum is 640×480, but we use 400×240 for performance.*
### Network Recommendations
- **Topology**: Wired PC + wireless 3DS ideal
- **Wi-Fi**: 2.4 GHz (3DS limitation), keep 3DS close to AP
- **QoS**: Prioritize port 9000 if router supports it
- **Interference**: Minimize other 2.4 GHz devices
### CPU Usage
**3DS**:
- Mono: ~40% CPU (one ARM11 core)
- Stereo: ~70% CPU
- Leaves headroom for system processes
**PC**:
- Negligible (<5% on modern CPU)
- JPEG decode + resize are lightweight
---
## Security Considerations
### Threat Model
**In Scope**:
- Local network (trusted environment)
- Personal/educational use
**Out of Scope**:
- Internet-facing deployment
- Untrusted network actors
### Current Security Posture
⚠️ **No Encryption**: Traffic is plaintext over TCP
⚠️ **No Authentication**: Server accepts any connection
⚠️ **No Integrity Checks**: Corrupted data may cause crashes
### Recommendations for Production Use
If deploying in a less-trusted environment:
1. **VPN Tunnel**: Use Wireguard/OpenVPN between 3DS and PC
2. **SSH Tunnel**: `ssh -L 9000:localhost:9000 user@server`
3. **Firewall Rules**: Restrict to known 3DS IP only
4. **Protocol Extension**: Add HMAC for integrity checking
Example SSH tunnel:
```bash
# On PC
ssh -L 9000:localhost:9000 user@localhost -N
# On 3DS
# Connect to 127.0.0.1:9000 (tunneled)
```
---
## Future Enhancements
### Potential Improvements
1. **H.264 Encoding**: Lower bandwidth, but requires hardware encoder (limited on 3DS)
2. **Adaptive Quality**: Adjust JPEG quality based on network conditions
3. **Frame Skipping**: Drop frames under high load to maintain real-time
4. **Audio Capture**: Add microphone support (separate audio stream)
5. **Recording**: Save to file on PC side
6. **Multi-Client**: Support multiple 3DS devices simultaneously
7. **WebRTC**: Browser-based viewing without virtual camera
### Known Limitations
- **3DS Hardware**: Max resolution 640×480, limited CPU/RAM
- **Network Latency**: ~100-200ms typical, inherent in Wi-Fi
- **Synchronization**: Stereo frames not hardware-synced (~10ms difference)
- **Frame Rate**: Limited by capture/encode pipeline, not true 30 FPS
---
## References
### 3DS Development
- libctru documentation: https://libctru.devkitpro.org/
- devkitPro: https://devkitpro.org/
- 3DS Homebrew: https://www.3dbrew.org/
### Libraries
- stb_image_write: https://github.com/nothings/stb
- pyvirtualcam: https://github.com/letmaik/pyvirtualcam
- v4l2loopback: https://github.com/umlaeute/v4l2loopback
### Standards
- YUV color space: ITU-R BT.601
- JPEG: ITU-T T.81 / ISO/IEC 10918-1
- TCP: RFC 793
+733
View File
@@ -0,0 +1,733 @@
# Troubleshooting Guide
This guide covers common issues and solutions for the 3DS Webcam Bridge.
---
## Table of Contents
- [Connection Issues](#connection-issues)
- [Virtual Camera Issues](#virtual-camera-issues)
- [3DS Application Issues](#3ds-application-issues)
- [Performance Issues](#performance-issues)
- [Build Issues](#build-issues)
- [Platform-Specific Issues](#platform-specific-issues)
---
## Connection Issues
### 3DS Shows "CONNECTING..." But Never Connects
**Symptoms**: 3DS app shows "CONNECTING..." indefinitely, never reaches "STREAMING" status.
**Possible Causes**:
1. **Wrong IP Address**
- **Check**: Verify the server IP on your 3DS matches your PC's IP
- **Linux**: Run `ip addr show` or `hostname -I`
- **Windows**: Run `ipconfig` in Command Prompt, look for IPv4 Address
- **Solution**: Update the IP in the 3DS app or config file
2. **PC Server Not Running**
- **Check**: Is `server.py` running on your PC?
- **Solution**: Start the server first, then start streaming on 3DS
3. **Firewall Blocking Connection**
- **Linux**:
```bash
# Check firewall status
sudo ufw status
# Allow port 9000
sudo ufw allow 9000/tcp
```
- **Windows**:
```powershell
# Run installer as Admin to add firewall rule automatically
.\scripts\windows\pc-install.ps1 -Port 9000
# Or manually add rule in Windows Defender Firewall
```
4. **Different Networks**
- **Check**: Ensure 3DS and PC are on the same network
- **Solution**: Connect both devices to the same Wi-Fi network
5. **Wrong Port**
- **Check**: Server port matches (default: 9000)
- **Solution**: Use same port on both sides, or specify with `--port` flag
**Verification Steps**:
```bash
# On PC (Linux/Mac)
nc -l 9000 # Listen on port 9000
# On PC (Windows PowerShell)
Test-NetConnection -ComputerName localhost -Port 9000
# Try connecting from 3DS
# If connection fails, it's a network/firewall issue
```
### Connection Drops Frequently
**Symptoms**: Stream starts but disconnects after a few seconds/minutes.
**Possible Causes**:
1. **Weak Wi-Fi Signal**
- **Solution**: Move 3DS closer to Wi-Fi router
- **Solution**: Reduce interference (microwave, cordless phones, etc.)
2. **Network Congestion**
- **Solution**: Pause large downloads/uploads
- **Solution**: Use QoS on router to prioritize 3DS
3. **PC Sleep/Screensaver**
- **Solution**: Disable sleep mode while streaming
- **Linux**: `sudo systemctl mask sleep.target suspend.target`
- **Windows**: Settings → Power & Sleep → Never
4. **Timeout Issues**
- **Solution**: Keep 3DS active (prevent sleep)
- **Note**: Auto-reconnect should kick in after 3 seconds
### "Connection Refused" Error
**Symptoms**: Server logs show "Connection refused" or similar error.
**Causes**:
- Port already in use
- Server crashed
**Solutions**:
```bash
# Linux: Check what's using port 9000
sudo lsof -i :9000
sudo netstat -tulpn | grep 9000
# Kill the process if needed
sudo kill <PID>
# Windows: Check port usage
netstat -ano | findstr :9000
# Kill process by PID
taskkill /PID <PID> /F
```
---
## Virtual Camera Issues
### Windows: "No Virtual Camera Found"
**Symptoms**: Server fails to start or can't find OBS Virtual Camera.
**Solutions**:
1. **Install OBS Studio**
```powershell
winget install -e --id OBSProject.OBSStudio
```
2. **Initialize Virtual Camera**
- Open OBS Studio
- Go to **Tools → VirtualCam → Start**
- Wait for confirmation
- Close OBS (virtual camera stays active)
3. **Verify Installation**
- Open Camera app (Windows built-in)
- Check if "OBS Virtual Camera" appears in device list
4. **Restart Required**
- Sometimes requires system restart after OBS installation
5. **pyvirtualcam Not Installed**
```powershell
.venv\Scripts\Activate.ps1
pip install pyvirtualcam
```
### Linux: "/dev/video0: Device or Resource Busy"
**Symptoms**: Server fails to open `/dev/video0`, "device busy" error.
**Causes**: Another application is using the device.
**Solutions**:
1. **Find Processes Using Device**
```bash
sudo fuser /dev/video0
```
2. **Close Applications**
- Close browser tabs with camera access
- Close Zoom, Discord, etc.
- Close any other camera applications
3. **Force Kill Processes**
```bash
sudo fuser -k /dev/video0
```
4. **Use Different Device**
```bash
# List available video devices
v4l2-ctl --list-devices
# Use different device
./pc-server/run_server.sh --device /dev/video2
```
### Linux: "/dev/video0: No Such Device"
**Symptoms**: v4l2loopback device doesn't exist.
**Solutions**:
1. **Load v4l2loopback Module**
```bash
./scripts/create-v4l2.sh
# Or manually
sudo modprobe v4l2loopback devices=1 video_nr=0 card_label="3DS Webcam" exclusive_caps=1
```
2. **Verify Module Loaded**
```bash
lsmod | grep v4l2loopback
v4l2-ctl --list-devices
```
3. **Install v4l2loopback**
```bash
sudo apt install v4l2loopback-dkms v4l2loopback-utils
# Or run installer
./scripts/pc-install.sh
```
4. **Check Device Permissions**
```bash
ls -l /dev/video*
# Add user to video group if needed
sudo usermod -a -G video $USER
# Log out and back in for group to take effect
```
### Virtual Camera Not Showing in Applications
**Symptoms**: Applications (Zoom, Chrome, etc.) don't see the virtual camera.
**Solutions**:
1. **Verify Device Exists**
- **Linux**: `ls /dev/video*`
- **Windows**: Check Camera app
2. **Restart Application**
- Some apps only detect cameras on launch
- Close and reopen the application
3. **Check exclusive_caps**
```bash
# Linux: Reload with exclusive_caps
sudo modprobe -r v4l2loopback
sudo modprobe v4l2loopback exclusive_caps=1
```
4. **Browser Permissions**
- Grant camera permission in browser settings
- Chrome: `chrome://settings/content/camera`
- Firefox: `about:preferences#privacy` → Permissions → Camera
5. **Server Must Be Running**
- Virtual camera only works when server is actively sending frames
- Start server first, then open application
---
## 3DS Application Issues
### 3DS App Won't Launch / Crashes on Start
**Symptoms**: App doesn't start or crashes immediately.
**Solutions**:
1. **Check Homebrew Launcher**
- Ensure Homebrew Launcher is up to date
- Try other homebrew apps to verify it's working
2. **Verify File Placement**
```
SD:/3ds/3ds-webcam-bridge/3ds-webcam-bridge.3dsx
```
3. **SD Card Issues**
- Check SD card is properly inserted
- Verify SD card is not corrupted
- Try different SD card if available
4. **Insufficient Memory**
- Close other homebrew apps before launching
- Restart 3DS to clear memory
5. **Build Issues**
- Rebuild the app: `./scripts/build-3ds.sh`
- Ensure stb_image_write.h is properly downloaded
### Camera Initialization Failed
**Symptoms**: App starts but shows camera initialization error.
**Solutions**:
1. **Close Other Apps**
- Some homebrew apps may hold camera resources
- Restart 3DS and try again
2. **Try Different Camera**
- Press SELECT to switch between Inner/Outer
- One camera may work if the other doesn't
3. **Camera Hardware Issue**
- Test camera with official Camera app
- If official app fails, hardware issue likely
### 3D Mode Checkbox Disabled
**Symptoms**: Can't enable 3D checkbox.
**This is Normal**: The 3D checkbox is **intentionally disabled** when Inner camera is selected.
**Reason**: The inner (front-facing) camera has only one lens and cannot provide stereo 3D.
**Solution**:
1. Press **SELECT** to switch to Outer cameras
2. The 3D checkbox will become enabled
3. Press **X** to toggle 3D mode
### Black Screen / No Image
**Symptoms**: Stream connects but shows black screen on PC.
**Solutions**:
1. **Check Camera Lens**
- Ensure lens cover is removed
- Clean lens with soft cloth
2. **Lighting Conditions**
- 3DS cameras need adequate lighting
- Try in brighter environment
3. **Auto-Exposure Issues**
- Point camera at evenly-lit scene
- Avoid pointing directly at lights
---
## Performance Issues
### Low Frame Rate (< 10 FPS)
**Symptoms**: Choppy/laggy stream, low FPS reported.
**Solutions**:
1. **Reduce Resolution** (requires rebuilding)
- Edit `3ds-app/source/camera_stream.h`
- Change `CAM_WIDTH` to 320 and rebuild
- Lower resolution = faster capture/encode
2. **Lower JPEG Quality**
- Edit `3ds-app/source/camera_stream.h`
- Change `JPEG_QUALITY` from 65 to 50
- Lower quality = faster encoding, less bandwidth
3. **Network Optimization**
- Move 3DS closer to Wi-Fi router
- Use 5 GHz Wi-Fi for PC (3DS stays on 2.4 GHz)
- Reduce network congestion
4. **Use Mono Mode**
- Disable 3D mode on 3DS
- Stereo mode requires 2× bandwidth and processing
5. **PC Performance**
```bash
# Check CPU usage
top # Linux
Task Manager # Windows
# Close unnecessary applications
```
### High Latency / Delay
**Symptoms**: Noticeable delay between 3DS movement and PC display.
**Expected Latency**: 100-200ms is normal due to:
- Camera capture: ~30ms
- JPEG encoding: ~40ms
- Network transmission: ~20-50ms
- JPEG decoding: ~10ms
- Display pipeline: ~20-40ms
**To Reduce**:
1. Use wired Ethernet for PC
2. Minimize Wi-Fi interference
3. Lower JPEG quality (trades quality for speed)
4. Use mono mode instead of stereo
### Stuttering / Frame Drops
**Symptoms**: Uneven frame delivery, periodic freezes.
**Solutions**:
1. **Network Issues**
- Check Wi-Fi signal strength
- Move 3DS closer to router
- Change Wi-Fi channel to less congested one
2. **CPU Throttling**
- 3DS may throttle when hot
- Let 3DS cool down
- Use in cooler environment
3. **Bandwidth Saturation**
- Pause downloads/uploads
- Close streaming services
- Monitor network with `iftop` (Linux) or Task Manager (Windows)
---
## Build Issues
### "DEVKITARM is not set"
**Symptoms**: Build fails with environment variable error.
**Solution**:
```bash
# Set environment variables
export DEVKITPRO=/opt/devkitpro
export DEVKITARM=$DEVKITPRO/devkitARM
export PATH=$DEVKITARM/bin:$PATH
# Add to ~/.bashrc for persistence
echo 'export DEVKITPRO=/opt/devkitpro' >> ~/.bashrc
echo 'export DEVKITARM=$DEVKITPRO/devkitARM' >> ~/.bashrc
echo 'export PATH=$DEVKITARM/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
```
### "stb_image_write.h: No Such File"
**Symptoms**: Compilation fails, can't find stb_image_write.h.
**Solution**:
```bash
# Download stb_image_write.h
wget https://raw.githubusercontent.com/nothings/stb/master/stb_image_write.h \
-O 3ds-app/source/third_party/stb_image_write.h
# Or run build script (downloads automatically)
./scripts/build-3ds.sh
```
### "libctru Not Found"
**Symptoms**: Linker errors, missing libctru symbols.
**Solution**:
```bash
# Install libctru
sudo dkp-pacman -S libctru
# Or run full installer
./scripts/install-devkitpro.sh
```
### Build Succeeds But .3dsx Not Created
**Symptoms**: Make completes but no output file.
**Check**:
```bash
# Look in dist directory
ls -la dist/
# Check for errors in make output
./scripts/build-3ds.sh 2>&1 | tee build.log
grep -i error build.log
```
**Solution**:
- Ensure `dist/` directory exists: `mkdir -p dist`
- Check Makefile paths
- Verify devkitARM installation
---
## Platform-Specific Issues
### Windows: "winget: command not found"
**Symptoms**: Installer script fails, winget not available.
**Solution**:
1. **Install App Installer** from Microsoft Store
2. **Or download manually**: https://github.com/microsoft/winget-cli/releases
3. **Update Windows**: winget requires Windows 10 1809 or later
### Windows: PowerShell Execution Policy Error
**Symptoms**: Scripts won't run, "execution policy" error.
**Solution**:
```powershell
# Temporary (current session only)
Set-ExecutionPolicy Bypass -Scope Process -Force
# Or run scripts with bypass flag
powershell -ExecutionPolicy Bypass -File .\scripts\windows\pc-install.ps1
```
### Linux: Permission Denied on Scripts
**Symptoms**: Can't execute `.sh` scripts.
**Solution**:
```bash
# Make scripts executable
chmod +x scripts/*.sh
chmod +x pc-server/run_server.sh
# Or run with bash
bash scripts/build-3ds.sh
```
### Linux: "apt-get: command not found"
**Symptoms**: Installer fails on non-Debian systems.
**Solution** (for other distros):
**Fedora/RHEL**:
```bash
sudo dnf install v4l2loopback akmod-v4l2loopback ffmpeg python3 python3-pip
```
**Arch**:
```bash
sudo pacman -S v4l2loopback-dkms ffmpeg python python-pip
```
Then manually create venv and install Python packages:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r pc-server/requirements.txt
```
---
## Diagnostic Commands
### Check System Status
**Linux**:
```bash
# Check v4l2loopback
lsmod | grep v4l2loopback
v4l2-ctl --list-devices
# Check network
ip addr show
ss -tulpn | grep 9000
# Test virtual camera
ffplay /dev/video0 # Requires ffmpeg
# Check Python environment
source .venv/bin/activate
python --version
pip list | grep -E "pyvirtualcam|pillow|numpy"
```
**Windows**:
```powershell
# Check OBS installation
Test-Path "C:\Program Files\obs-studio\bin\64bit\obs64.exe"
# Check Python
python --version
pip list | findstr /I "pyvirtualcam pillow numpy"
# Check network
ipconfig
netstat -ano | findstr :9000
# Test virtual camera
# Open Camera app and check device list
```
### Enable Verbose Logging
```bash
# Linux
./pc-server/run_server.sh --verbose
# Windows
.\pc-server\run_server.ps1 -Verbose
```
This will show detailed frame information:
- Frame type (mono/stereo)
- JPEG sizes
- Dimensions
- Processing time
---
## Getting Help
### Before Asking for Help
1. **Check this guide** for your specific issue
2. **Review logs** with `--verbose` flag
3. **Verify requirements** (network, versions, etc.)
4. **Try basic troubleshooting** (restart, reconnect, etc.)
### When Reporting Issues
Include:
1. **Platform Information**
- OS (Ubuntu 22.04, Windows 11, etc.)
- Python version: `python --version`
- 3DS model (Old 3DS, New 3DS XL, etc.)
2. **Error Messages**
- Full error output
- Verbose logs if available
3. **Steps to Reproduce**
- What were you doing when it failed?
- Can you reproduce it consistently?
4. **Configuration**
- Command line arguments used
- Config file contents (if any)
- Network topology (router, switches, etc.)
5. **What You've Tried**
- Troubleshooting steps already attempted
- Results of those attempts
### Community Resources
- **GitHub Issues**: https://github.com/your-username/3ds-webcam-bridge/issues
- **3DS Homebrew Community**: https://www.3dbrew.org/
- **devkitPro Forums**: https://devkitpro.org/viewforum.php
---
## Known Issues & Workarounds
### Issue: Stereo Frames Slightly Desynchronized
**Impact**: Left and right frames may be 1-2 frames apart.
**Workaround**: None currently. This is due to sequential capture. Impact is minimal (<50ms difference).
**Future Fix**: Hardware-synchronized capture (if possible via libctru extensions).
### Issue: Windows Dual Mode Requires Manual OBS Config
**Impact**: Can't easily create multiple virtual cameras on Windows.
**Workaround**:
1. Use OBS plugins to create multiple virtual cameras
2. Or use side-by-side mode instead
**Future Fix**: Investigate alternative virtual camera drivers for Windows.
### Issue: 3DS Sleep Mode Disconnects
**Impact**: 3DS entering sleep mode kills the stream.
**Workaround**: Keep 3DS active, disable auto-sleep in system settings.
**Future Fix**: Detect sleep and reconnect automatically.
---
## Advanced Troubleshooting
### Network Packet Capture
**Linux**:
```bash
# Capture traffic on port 9000
sudo tcpdump -i any -w capture.pcap port 9000
# Analyze with Wireshark
wireshark capture.pcap
```
**Windows**:
```powershell
# Use Wireshark directly
# Filter: tcp.port == 9000
```
### Debugging Python Server
```bash
# Run with Python debugger
python -m pdb pc-server/server.py --verbose
# Or add breakpoints in code
import pdb; pdb.set_trace()
```
### Testing Without 3DS
Create a test client to verify server:
```python
import socket
import struct
sock = socket.socket()
sock.connect(('localhost', 9000))
# Send mono frame header
header = b'\x00\x01' + struct.pack('>I', 1024) # 1KB test frame
sock.send(header)
sock.send(b'\xff\xd8' + b'\x00' * 1022 + b'\xff\xd9') # Fake JPEG
sock.close()
```
---
**Last Updated**: 2025-10-28
+11
View File
@@ -0,0 +1,11 @@
# 3DS Webcam Bridge - Configuration File
# Place this file at: SD:/3ds/3ds-webcam-bridge/config.txt
#
# You can also edit IP/Port by pressing Y button in the app,
# which will automatically save to this file.
# Server IP address (your PC's IP)
ip=192.168.1.100
# Server port
port=9000
+4
View File
@@ -0,0 +1,4 @@
pillow>=10.0.0
numpy>=1.24.0
pyvirtualcam>=0.11.0
pyfakewebcam>=0.1.0; platform_system == "Linux"
+55
View File
@@ -0,0 +1,55 @@
# Run the 3DS Webcam Bridge PC server (Windows)
param(
[string]$Listen = "0.0.0.0",
[int]$Port = 9000,
[string]$StereoMode = "off",
[string]$Device = $null,
[string]$DualDevices = $null,
[int]$Width = 640,
[int]$Height = 480,
[int]$Fps = 15,
[string]$PreferBackend = "pyvirtualcam",
[switch]$Verbose
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$VenvDir = Join-Path (Split-Path -Parent $ScriptDir) ".venv"
$VenvActivate = Join-Path $VenvDir "Scripts\Activate.ps1"
# Activate virtual environment if it exists
if (Test-Path $VenvActivate) {
Write-Host "Activating virtual environment..."
& $VenvActivate
} else {
Write-Warning "Virtual environment not found at $VenvDir"
Write-Warning "Run ..\scripts\windows\pc-install.ps1 first to set up dependencies"
}
# Build command line arguments
$Args = @(
"--listen", $Listen,
"--port", $Port,
"--stereo-mode", $StereoMode,
"--width", $Width,
"--height", $Height,
"--fps", $Fps,
"--prefer-backend", $PreferBackend
)
if ($Device) {
$Args += "--device"
$Args += $Device
}
if ($DualDevices) {
$Args += "--dual-devices"
$Args += $DualDevices
}
if ($Verbose) {
$Args += "--verbose"
}
# Run server
$ServerScript = Join-Path $ScriptDir "server.py"
python $ServerScript @Args
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Run the 3DS Webcam Bridge PC server (Linux)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
VENV_DIR="$SCRIPT_DIR/../.venv"
# Activate virtual environment if it exists
if [ -d "$VENV_DIR" ]; then
source "$VENV_DIR/bin/activate"
else
echo "Warning: Virtual environment not found at $VENV_DIR"
echo "Run ../scripts/pc-install.sh first to set up dependencies"
fi
# Run server with provided arguments or defaults
python3 "$SCRIPT_DIR/server.py" "$@"
+599
View File
@@ -0,0 +1,599 @@
#!/usr/bin/env python3
"""
3DS Webcam Bridge - PC Server
Receives camera streams from 3DS and exposes them as virtual webcams.
Supports mono and stereo modes with multiple output configurations.
"""
import argparse
import io
import socket
import struct
import sys
import time
import platform
from typing import Optional, Tuple
import numpy as np
from PIL import Image
# Backend selection
BACKEND_PYVIRTUALCAM = "pyvirtualcam"
BACKEND_PYFAKEWEBCAM = "pyfakewebcam"
BACKEND_FFMPEG = "ffmpeg"
# Protocol constants
PROTO_VERSION = 0x01
PROTO_FLAG_STEREO = 0x01
# Global state
g_verbose = False
def log(msg):
"""Print log message."""
print(f"[INFO] {msg}")
def log_verbose(msg):
"""Print verbose log message."""
if g_verbose:
print(f"[VERBOSE] {msg}")
def log_error(msg):
"""Print error message."""
print(f"[ERROR] {msg}", file=sys.stderr)
def recv_all(conn: socket.socket, n: int) -> bytes:
"""Receive exactly n bytes from socket."""
data = b''
while len(data) < n:
chunk = conn.recv(n - len(data))
if not chunk:
raise ConnectionError("Connection closed by client")
data += chunk
return data
def read_frame(conn: socket.socket) -> Tuple[Optional[Image.Image], Optional[Image.Image]]:
"""
Read a frame from the 3DS.
Returns (left_image, right_image) where right_image is None for mono mode.
"""
# Read header
header = recv_all(conn, 2)
flags = header[0]
version = header[1]
if version != PROTO_VERSION:
log_error(f"Unsupported protocol version: {version}")
return None, None
stereo = (flags & PROTO_FLAG_STEREO) != 0
if not stereo:
# Mono frame: [4-byte length][JPEG]
length_bytes = recv_all(conn, 4)
length = struct.unpack('>I', length_bytes)[0]
log_verbose(f"Receiving mono frame: {length} bytes")
jpeg_data = recv_all(conn, length)
left_img = Image.open(io.BytesIO(jpeg_data)).convert('RGB')
return left_img, None
else:
# Stereo frame: [2B wL][2B hL][2B wR][2B hR][4B left_len][4B right_len][left_jpeg][right_jpeg]
dims = recv_all(conn, 8)
wL, hL, wR, hR = struct.unpack('>HHHH', dims)
lengths = recv_all(conn, 8)
left_len, right_len = struct.unpack('>II', lengths)
log_verbose(f"Receiving stereo frame: left={wL}x{hL} ({left_len} bytes), right={wR}x{hR} ({right_len} bytes)")
left_data = recv_all(conn, left_len)
right_data = recv_all(conn, right_len)
left_img = Image.open(io.BytesIO(left_data)).convert('RGB')
right_img = Image.open(io.BytesIO(right_data)).convert('RGB')
return left_img, right_img
def compose_sbs(left: Image.Image, right: Image.Image, width: int, height: int) -> np.ndarray:
"""Compose side-by-side stereo image."""
left_resized = left.resize((width, height), Image.BILINEAR)
right_resized = right.resize((width, height), Image.BILINEAR)
left_arr = np.array(left_resized)
right_arr = np.array(right_resized)
# Concatenate horizontally
sbs = np.concatenate([left_arr, right_arr], axis=1)
return sbs
def compose_anaglyph(left: Image.Image, right: Image.Image, width: int, height: int) -> np.ndarray:
"""Compose red-cyan anaglyph image."""
left_resized = left.resize((width, height), Image.BILINEAR)
right_resized = right.resize((width, height), Image.BILINEAR)
left_arr = np.array(left_resized)
right_arr = np.array(right_resized)
# Red-cyan anaglyph: red from left, green+blue from right
anaglyph = np.zeros_like(left_arr)
anaglyph[:, :, 0] = left_arr[:, :, 0] # Red from left
anaglyph[:, :, 1] = right_arr[:, :, 1] # Green from right
anaglyph[:, :, 2] = right_arr[:, :, 2] # Blue from right
return anaglyph
class VirtualCameraBackend:
"""Base class for virtual camera backends."""
def __init__(self, args):
self.args = args
def send_frame(self, frame: np.ndarray):
"""Send a frame to the virtual camera."""
raise NotImplementedError
def send_dual_frames(self, left: np.ndarray, right: np.ndarray):
"""Send dual frames (for dual device mode)."""
raise NotImplementedError
def close(self):
"""Close the backend."""
pass
class PyVirtualCamBackend(VirtualCameraBackend):
"""Backend using pyvirtualcam."""
def __init__(self, args):
super().__init__(args)
try:
import pyvirtualcam
self.pyvirtualcam = pyvirtualcam
except ImportError:
raise RuntimeError("pyvirtualcam is not installed. Install with: pip install pyvirtualcam")
self.camera = None
self.camera_left = None
self.camera_right = None
def _open_single_camera(self, width, height, fps, device=None):
"""Open a single pyvirtualcam device."""
kwargs = {
'width': width,
'height': height,
'fps': fps,
'print_fps': g_verbose
}
if device is not None:
# Device can be a name (string) or index (int)
try:
device_int = int(device)
kwargs['device'] = device_int
except ValueError:
kwargs['device'] = device
return self.pyvirtualcam.Camera(**kwargs)
def setup_single(self):
"""Setup single camera output."""
width = self.args.width
height = self.args.height
# For SBS mode, double the width
if self.args.stereo_mode == 'sbs':
width = width * 2
self.camera = self._open_single_camera(
width=width,
height=height,
fps=self.args.fps,
device=self.args.device
)
log(f"Opened virtual camera: {width}x{height} @ {self.args.fps} FPS")
def setup_dual(self):
"""Setup dual camera output."""
devices = self.args.dual_devices.split(',')
if len(devices) != 2:
raise ValueError("dual_devices must specify exactly 2 devices separated by comma")
left_device = devices[0].strip()
right_device = devices[1].strip()
self.camera_left = self._open_single_camera(
width=self.args.width,
height=self.args.height,
fps=self.args.fps,
device=left_device if left_device else None
)
self.camera_right = self._open_single_camera(
width=self.args.width,
height=self.args.height,
fps=self.args.fps,
device=right_device if right_device else None
)
log(f"Opened dual virtual cameras: {self.args.width}x{self.args.height} @ {self.args.fps} FPS")
def send_frame(self, frame: np.ndarray):
"""Send frame to single camera."""
if self.camera is None:
self.setup_single()
self.camera.send(frame)
def send_dual_frames(self, left: np.ndarray, right: np.ndarray):
"""Send frames to dual cameras."""
if self.camera_left is None or self.camera_right is None:
self.setup_dual()
self.camera_left.send(left)
self.camera_right.send(right)
def close(self):
"""Close cameras."""
if self.camera:
self.camera.close()
if self.camera_left:
self.camera_left.close()
if self.camera_right:
self.camera_right.close()
class PyFakeWebcamBackend(VirtualCameraBackend):
"""Backend using pyfakewebcam (Linux only)."""
def __init__(self, args):
super().__init__(args)
if platform.system() != 'Linux':
raise RuntimeError("pyfakewebcam is only available on Linux")
try:
import pyfakewebcam
self.pyfakewebcam = pyfakewebcam
except ImportError:
raise RuntimeError("pyfakewebcam is not installed. Install with: pip install pyfakewebcam")
self.camera = None
self.camera_left = None
self.camera_right = None
def setup_single(self):
"""Setup single camera output."""
width = self.args.width
height = self.args.height
if self.args.stereo_mode == 'sbs':
width = width * 2
device = self.args.device if self.args.device else '/dev/video0'
self.camera = self.pyfakewebcam.FakeWebcam(device, width, height)
log(f"Opened pyfakewebcam device: {device} ({width}x{height})")
def setup_dual(self):
"""Setup dual camera output."""
devices = self.args.dual_devices.split(',')
if len(devices) != 2:
raise ValueError("dual_devices must specify exactly 2 devices separated by comma")
self.camera_left = self.pyfakewebcam.FakeWebcam(
devices[0].strip(), self.args.width, self.args.height)
self.camera_right = self.pyfakewebcam.FakeWebcam(
devices[1].strip(), self.args.width, self.args.height)
log(f"Opened dual pyfakewebcam devices: {devices[0]}, {devices[1]}")
def send_frame(self, frame: np.ndarray):
"""Send frame to single camera."""
if self.camera is None:
self.setup_single()
self.camera.schedule_frame(frame)
def send_dual_frames(self, left: np.ndarray, right: np.ndarray):
"""Send frames to dual cameras."""
if self.camera_left is None or self.camera_right is None:
self.setup_dual()
self.camera_left.schedule_frame(left)
self.camera_right.schedule_frame(right)
class FFmpegBackend(VirtualCameraBackend):
"""Backend using ffmpeg pipe to v4l2 (Linux only)."""
def __init__(self, args):
super().__init__(args)
if platform.system() != 'Linux':
raise RuntimeError("ffmpeg backend is only available on Linux")
import subprocess
self.subprocess = subprocess
self.process = None
self.process_left = None
self.process_right = None
def setup_single(self):
"""Setup single ffmpeg output."""
width = self.args.width
height = self.args.height
if self.args.stereo_mode == 'sbs':
width = width * 2
device = self.args.device if self.args.device else '/dev/video0'
cmd = [
'ffmpeg',
'-f', 'rawvideo',
'-pixel_format', 'rgb24',
'-video_size', f'{width}x{height}',
'-framerate', str(self.args.fps),
'-i', '-',
'-f', 'v4l2',
device
]
self.process = self.subprocess.Popen(cmd, stdin=self.subprocess.PIPE)
log(f"Opened ffmpeg pipe to {device} ({width}x{height})")
def setup_dual(self):
"""Setup dual ffmpeg output."""
devices = self.args.dual_devices.split(',')
if len(devices) != 2:
raise ValueError("dual_devices must specify exactly 2 devices")
cmd_template = [
'ffmpeg',
'-f', 'rawvideo',
'-pixel_format', 'rgb24',
'-video_size', f'{self.args.width}x{self.args.height}',
'-framerate', str(self.args.fps),
'-i', '-',
'-f', 'v4l2',
None # Device placeholder
]
cmd_left = cmd_template[:]
cmd_left[-1] = devices[0].strip()
self.process_left = self.subprocess.Popen(cmd_left, stdin=self.subprocess.PIPE)
cmd_right = cmd_template[:]
cmd_right[-1] = devices[1].strip()
self.process_right = self.subprocess.Popen(cmd_right, stdin=self.subprocess.PIPE)
log(f"Opened dual ffmpeg pipes to {devices[0]}, {devices[1]}")
def send_frame(self, frame: np.ndarray):
"""Send frame via ffmpeg."""
if self.process is None:
self.setup_single()
self.process.stdin.write(frame.tobytes())
self.process.stdin.flush()
def send_dual_frames(self, left: np.ndarray, right: np.ndarray):
"""Send frames via dual ffmpeg pipes."""
if self.process_left is None or self.process_right is None:
self.setup_dual()
self.process_left.stdin.write(left.tobytes())
self.process_left.stdin.flush()
self.process_right.stdin.write(right.tobytes())
self.process_right.stdin.flush()
def close(self):
"""Close ffmpeg processes."""
if self.process:
self.process.stdin.close()
self.process.wait()
if self.process_left:
self.process_left.stdin.close()
self.process_left.wait()
if self.process_right:
self.process_right.stdin.close()
self.process_right.wait()
def create_backend(args) -> VirtualCameraBackend:
"""Create appropriate backend based on preference."""
backend_name = args.prefer_backend
if backend_name == BACKEND_PYVIRTUALCAM:
try:
return PyVirtualCamBackend(args)
except RuntimeError as e:
log_error(f"Failed to create pyvirtualcam backend: {e}")
if platform.system() == 'Linux':
log("Falling back to pyfakewebcam...")
backend_name = BACKEND_PYFAKEWEBCAM
else:
raise
if backend_name == BACKEND_PYFAKEWEBCAM:
try:
return PyFakeWebcamBackend(args)
except RuntimeError as e:
log_error(f"Failed to create pyfakewebcam backend: {e}")
log("Falling back to ffmpeg...")
backend_name = BACKEND_FFMPEG
if backend_name == BACKEND_FFMPEG:
return FFmpegBackend(args)
raise RuntimeError(f"No suitable backend found")
def process_stream(conn: socket.socket, addr, args):
"""Process incoming stream from 3DS."""
log(f"Client connected from {addr}")
backend = create_backend(args)
frame_count = 0
start_time = time.time()
try:
while True:
# Read frame from 3DS
left_img, right_img = read_frame(conn)
if left_img is None:
break
# Process based on mode
if right_img is None:
# Mono mode
log_verbose("Processing mono frame")
left_resized = left_img.resize((args.width, args.height), Image.BILINEAR)
frame = np.array(left_resized)
backend.send_frame(frame)
else:
# Stereo mode
log_verbose("Processing stereo frame")
if args.stereo_mode == 'off':
# Send left eye only
left_resized = left_img.resize((args.width, args.height), Image.BILINEAR)
frame = np.array(left_resized)
backend.send_frame(frame)
elif args.stereo_mode == 'sbs':
# Side-by-side
frame = compose_sbs(left_img, right_img, args.width, args.height)
backend.send_frame(frame)
elif args.stereo_mode == 'anaglyph':
# Red-cyan anaglyph
frame = compose_anaglyph(left_img, right_img, args.width, args.height)
backend.send_frame(frame)
elif args.stereo_mode == 'dual':
# Dual devices
left_resized = left_img.resize((args.width, args.height), Image.BILINEAR)
right_resized = right_img.resize((args.width, args.height), Image.BILINEAR)
left_arr = np.array(left_resized)
right_arr = np.array(right_resized)
backend.send_dual_frames(left_arr, right_arr)
frame_count += 1
if frame_count % 30 == 0:
elapsed = time.time() - start_time
fps = frame_count / elapsed
log(f"Processed {frame_count} frames ({fps:.1f} FPS)")
except ConnectionError as e:
log_error(f"Connection error: {e}")
except KeyboardInterrupt:
log("Interrupted by user")
except Exception as e:
log_error(f"Error processing stream: {e}")
import traceback
traceback.print_exc()
finally:
backend.close()
log(f"Client disconnected: {addr}")
def main():
global g_verbose
parser = argparse.ArgumentParser(
description='3DS Webcam Bridge - PC Server',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Linux - mono mode with default settings
%(prog)s --device /dev/video0
# Linux - side-by-side stereo
%(prog)s --stereo-mode sbs --width 400 --height 240 --device /dev/video0
# Linux - dual devices for left/right eyes
%(prog)s --stereo-mode dual --dual-devices /dev/video0,/dev/video1
# Windows - side-by-side with OBS Virtual Camera
%(prog)s --stereo-mode sbs --device "OBS Virtual Camera"
# Windows - dual OBS virtual cameras
%(prog)s --stereo-mode dual --dual-devices "OBS Virtual Camera,OBS Virtual Camera 2"
"""
)
parser.add_argument('--listen', default='0.0.0.0',
help='Listen address (default: 0.0.0.0)')
parser.add_argument('--port', type=int, default=9000,
help='Listen port (default: 9000)')
parser.add_argument('--mode', choices=['mono', 'auto'], default='auto',
help='Force mono or auto-detect (default: auto)')
parser.add_argument('--stereo-mode', choices=['off', 'sbs', 'anaglyph', 'dual'],
default='off',
help='Stereo output mode (default: off)')
parser.add_argument('--device', default=None,
help='Virtual camera device (Linux: /dev/videoX, Windows: device name or index)')
parser.add_argument('--dual-devices', default=None,
help='Dual devices for stereo (comma-separated)')
parser.add_argument('--width', type=int, default=640,
help='Output width per eye (default: 640)')
parser.add_argument('--height', type=int, default=480,
help='Output height (default: 480)')
parser.add_argument('--fps', type=int, default=15,
help='Output FPS (default: 15)')
parser.add_argument('--prefer-backend',
choices=[BACKEND_PYVIRTUALCAM, BACKEND_PYFAKEWEBCAM, BACKEND_FFMPEG],
default=BACKEND_PYVIRTUALCAM,
help='Preferred backend (default: pyvirtualcam)')
parser.add_argument('--verbose', action='store_true',
help='Verbose output')
args = parser.parse_args()
g_verbose = args.verbose
# Validate arguments
if args.stereo_mode == 'dual' and not args.dual_devices:
parser.error("--dual-devices is required when using --stereo-mode dual")
# Create listening socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((args.listen, args.port))
sock.listen(1)
log(f"Listening on {args.listen}:{args.port}")
log(f"Stereo mode: {args.stereo_mode}")
log(f"Backend: {args.prefer_backend}")
while True:
log("Waiting for connection...")
conn, addr = sock.accept()
try:
process_stream(conn, addr, args)
finally:
conn.close()
except KeyboardInterrupt:
log("\nShutting down...")
except Exception as e:
log_error(f"Server error: {e}")
import traceback
traceback.print_exc()
finally:
sock.close()
if __name__ == '__main__':
main()
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# Build the 3DS webcam app
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
APP_DIR="$PROJECT_ROOT/3ds-app"
DIST_DIR="$PROJECT_ROOT/dist"
echo "======================================"
echo "Building 3DS Webcam Bridge"
echo "======================================"
echo ""
# Check for devkitARM
if [ -z "$DEVKITARM" ]; then
echo "Error: DEVKITARM is not set!"
echo ""
echo "Please run: export DEVKITARM=/opt/devkitpro/devkitARM"
echo "Or run: ./scripts/install-devkitpro.sh"
exit 1
fi
# Check for stb_image_write.h
STB_HEADER="$APP_DIR/source/third_party/stb_image_write.h"
if ! grep -q "stbi_write_jpg_to_func" "$STB_HEADER" 2>/dev/null || grep -q "PLACEHOLDER" "$STB_HEADER"; then
echo "Warning: stb_image_write.h appears to be a placeholder!"
echo ""
echo "Downloading the real stb_image_write.h..."
wget https://raw.githubusercontent.com/nothings/stb/master/stb_image_write.h \
-O "$STB_HEADER" 2>/dev/null || {
echo ""
echo "Error: Could not download stb_image_write.h"
echo "Please download it manually from:"
echo " https://raw.githubusercontent.com/nothings/stb/master/stb_image_write.h"
echo "And place it at:"
echo " $STB_HEADER"
exit 1
}
echo "Downloaded successfully!"
echo ""
fi
# Create dist directory
mkdir -p "$DIST_DIR"
# Build
echo "Building..."
cd "$APP_DIR"
make clean
make -j$(nproc)
echo ""
echo "======================================"
echo "Build complete!"
echo "======================================"
echo ""
echo "Output files:"
ls -lh "$DIST_DIR"/3ds-webcam-bridge.*
echo ""
echo "To install on your 3DS:"
echo " 1. Copy $DIST_DIR/3ds-webcam-bridge.3dsx to your SD card at:"
echo " SD:/3ds/3ds-webcam-bridge/"
echo " 2. Launch via Homebrew Launcher"
echo ""
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# Create v4l2loopback virtual camera device(s)
set -e
DUAL=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--dual)
DUAL=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--dual]"
exit 1
;;
esac
done
echo "======================================"
echo "v4l2loopback Device Setup"
echo "======================================"
echo ""
# Check if v4l2loopback is installed
if ! modinfo v4l2loopback &> /dev/null; then
echo "Error: v4l2loopback is not installed!"
echo "Run: ./scripts/pc-install.sh"
exit 1
fi
# Unload existing module if loaded
if lsmod | grep -q v4l2loopback; then
echo "Unloading existing v4l2loopback module..."
sudo modprobe -r v4l2loopback
fi
# Load module with appropriate parameters
if [ "$DUAL" = true ]; then
echo "Creating dual virtual camera devices..."
sudo modprobe v4l2loopback \
devices=2 \
video_nr=0,1 \
card_label="3DS Webcam Left","3DS Webcam Right" \
exclusive_caps=1,1
echo ""
echo "Created devices:"
echo " /dev/video0 - 3DS Webcam Left"
echo " /dev/video1 - 3DS Webcam Right"
echo ""
echo "To use with dual mode:"
echo " ./pc-server/run_server.sh --stereo-mode dual --dual-devices /dev/video0,/dev/video1"
else
echo "Creating single virtual camera device..."
sudo modprobe v4l2loopback \
devices=1 \
video_nr=0 \
card_label="3DS Webcam" \
exclusive_caps=1
echo ""
echo "Created device:"
echo " /dev/video0 - 3DS Webcam"
echo ""
echo "To use:"
echo " ./pc-server/run_server.sh --device /dev/video0"
fi
echo ""
echo "Verify with: v4l2-ctl --list-devices"
echo ""
echo "To make this persistent across reboots, add to /etc/modules-load.d/v4l2loopback.conf:"
if [ "$DUAL" = true ]; then
echo " options v4l2loopback devices=2 video_nr=0,1 card_label=\"3DS Webcam Left\",\"3DS Webcam Right\" exclusive_caps=1,1"
else
echo " options v4l2loopback devices=1 video_nr=0 card_label=\"3DS Webcam\" exclusive_caps=1"
fi
echo ""
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Install devkitPro toolchain for 3DS development (Debian/Ubuntu)
set -e
echo "======================================"
echo "devkitPro Toolchain Installer"
echo "======================================"
echo ""
# Check if already installed
if command -v dkp-pacman &> /dev/null; then
echo "devkitPro is already installed!"
echo "devkitARM location: $DEVKITARM"
echo ""
echo "To update, run: sudo dkp-pacman -Syu"
exit 0
fi
echo "This script will install devkitPro and the 3DS development tools."
echo "You may be prompted for your password (sudo)."
echo ""
# Download and install devkitPro pacman
echo "Downloading devkitPro pacman package..."
wget https://github.com/devkitPro/pacman/releases/latest/download/devkitpro-pacman.amd64.deb -O /tmp/devkitpro-pacman.deb
echo "Installing devkitPro pacman..."
sudo dpkg -i /tmp/devkitpro-pacman.deb || sudo apt-get install -f -y
rm /tmp/devkitpro-pacman.deb
# Install devkitARM and libraries
echo ""
echo "Installing devkitARM and 3DS libraries..."
sudo dkp-pacman -S --noconfirm devkitARM libctru citro3d 3dstools
echo ""
echo "======================================"
echo "Installation complete!"
echo "======================================"
echo ""
echo "IMPORTANT: Add these lines to your ~/.bashrc or ~/.zshrc:"
echo ""
echo " export DEVKITPRO=/opt/devkitpro"
echo " export DEVKITARM=\$DEVKITPRO/devkitARM"
echo " export PATH=\$DEVKITARM/bin:\$PATH"
echo ""
echo "Then run: source ~/.bashrc"
echo ""
echo "Or run these commands now:"
echo " export DEVKITPRO=/opt/devkitpro"
echo " export DEVKITARM=\$DEVKITPRO/devkitARM"
echo " export PATH=\$DEVKITARM/bin:\$PATH"
echo ""
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# Install PC server dependencies (Linux - Debian/Ubuntu)
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
VENV_DIR="$PROJECT_ROOT/.venv"
echo "======================================"
echo "PC Server Installer (Linux)"
echo "======================================"
echo ""
# Update package list
echo "Updating package list..."
sudo apt-get update
# Install system dependencies
echo ""
echo "Installing system dependencies..."
sudo apt-get install -y \
v4l2loopback-dkms \
v4l2loopback-utils \
ffmpeg \
python3 \
python3-venv \
python3-pip \
python3-dev \
build-essential
# Create virtual environment
echo ""
echo "Creating Python virtual environment..."
if [ -d "$VENV_DIR" ]; then
echo "Virtual environment already exists, updating..."
else
python3 -m venv "$VENV_DIR"
fi
# Activate and install Python packages
echo ""
echo "Installing Python packages..."
source "$VENV_DIR/bin/activate"
pip install --upgrade pip
pip install -r "$PROJECT_ROOT/pc-server/requirements.txt"
echo ""
echo "======================================"
echo "Installation complete!"
echo "======================================"
echo ""
echo "Next steps:"
echo ""
echo "1. Create v4l2loopback device(s):"
echo " For single device:"
echo " ./scripts/create-v4l2.sh"
echo " For dual devices (stereo):"
echo " ./scripts/create-v4l2.sh --dual"
echo ""
echo "2. Run the server:"
echo " ./pc-server/run_server.sh --device /dev/video0"
echo ""
echo "3. For stereo side-by-side:"
echo " ./pc-server/run_server.sh --stereo-mode sbs --width 400 --height 240"
echo ""
echo "4. For dual devices:"
echo " ./pc-server/run_server.sh --stereo-mode dual --dual-devices /dev/video0,/dev/video1"
echo ""
+104
View File
@@ -0,0 +1,104 @@
# Helper functions for Windows installation
function Test-WingetInstalled {
<#
.SYNOPSIS
Check if winget is installed
#>
try {
$null = Get-Command winget -ErrorAction Stop
return $true
} catch {
return $false
}
}
function Test-CommandInstalled {
<#
.SYNOPSIS
Check if a command is available
#>
param(
[string]$Command
)
try {
$null = Get-Command $Command -ErrorAction Stop
return $true
} catch {
return $false
}
}
function Install-WithWinget {
<#
.SYNOPSIS
Install a package using winget
#>
param(
[string]$PackageId,
[string]$Name
)
Write-Host "Installing $Name..." -ForegroundColor Cyan
$result = winget install -e --id $PackageId --accept-package-agreements --accept-source-agreements
if ($LASTEXITCODE -eq 0) {
Write-Host "$Name installed successfully!" -ForegroundColor Green
return $true
} else {
Write-Warning "$Name installation may have failed. Exit code: $LASTEXITCODE"
return $false
}
}
function Add-FirewallRule {
<#
.SYNOPSIS
Add a Windows Defender Firewall rule for the server
#>
param(
[int]$Port,
[string]$RuleName = "3DS Webcam Bridge"
)
Write-Host "Checking firewall rules..." -ForegroundColor Cyan
# Check if rule already exists
$existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue
if ($existingRule) {
Write-Host "Firewall rule already exists." -ForegroundColor Yellow
return $true
}
try {
New-NetFirewallRule `
-DisplayName $RuleName `
-Direction Inbound `
-Protocol TCP `
-LocalPort $Port `
-Action Allow `
-Profile Private,Domain `
-ErrorAction Stop | Out-Null
Write-Host "Firewall rule added successfully!" -ForegroundColor Green
return $true
} catch {
Write-Warning "Failed to add firewall rule: $_"
Write-Warning "You may need to manually add a firewall rule for port $Port"
return $false
}
}
function Test-IsAdmin {
<#
.SYNOPSIS
Check if running as administrator
#>
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
Export-ModuleMember -Function Test-WingetInstalled, Test-CommandInstalled, Install-WithWinget, Add-FirewallRule, Test-IsAdmin
+160
View File
@@ -0,0 +1,160 @@
# PC Server Installer for Windows 10/11
# This script installs all dependencies for the 3DS Webcam Bridge PC server
param(
[int]$Port = 9000,
[switch]$SkipFirewall,
[switch]$Force
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir)
$HelpersModule = Join-Path $ScriptDir "helpers.psm1"
# Import helpers
Import-Module $HelpersModule -Force
Write-Host "======================================" -ForegroundColor Cyan
Write-Host "3DS Webcam Bridge - PC Server Installer" -ForegroundColor Cyan
Write-Host "Windows 10/11" -ForegroundColor Cyan
Write-Host "======================================" -ForegroundColor Cyan
Write-Host ""
# Check for admin privileges (required for some operations)
$isAdmin = Test-IsAdmin
if (-not $isAdmin) {
Write-Warning "Not running as Administrator. Some features may require admin privileges."
Write-Warning "To enable firewall rules and ensure full functionality, run as Administrator."
Write-Host ""
$response = Read-Host "Continue anyway? (y/n)"
if ($response -ne 'y') {
exit 1
}
}
# Check for winget
Write-Host "Checking for winget..." -ForegroundColor Cyan
if (-not (Test-WingetInstalled)) {
Write-Host ""
Write-Host "ERROR: winget is not installed!" -ForegroundColor Red
Write-Host ""
Write-Host "winget is required to install dependencies automatically."
Write-Host "Please install 'App Installer' from the Microsoft Store, or"
Write-Host "download it from:"
Write-Host " https://github.com/microsoft/winget-cli/releases"
Write-Host ""
exit 1
}
Write-Host "winget found!" -ForegroundColor Green
Write-Host ""
# Install Python 3
Write-Host "Checking for Python 3..." -ForegroundColor Cyan
if (-not (Test-CommandInstalled python) -and -not (Test-CommandInstalled python3)) {
Write-Host "Python 3 not found. Installing..." -ForegroundColor Yellow
Install-WithWinget -PackageId "Python.Python.3.11" -Name "Python 3.11"
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
Write-Host ""
Write-Host "NOTE: You may need to restart your terminal for Python to be available in PATH" -ForegroundColor Yellow
Write-Host ""
} else {
Write-Host "Python 3 is already installed!" -ForegroundColor Green
$pythonVersion = python --version 2>&1
Write-Host " $pythonVersion" -ForegroundColor Gray
}
Write-Host ""
# Install OBS Studio (for Virtual Camera)
Write-Host "Checking for OBS Studio..." -ForegroundColor Cyan
$obsInstalled = Test-Path "C:\Program Files\obs-studio\bin\64bit\obs64.exe"
if (-not $obsInstalled -or $Force) {
Write-Host "OBS Studio not found. Installing..." -ForegroundColor Yellow
Write-Host ""
Write-Host "OBS Studio provides the Virtual Camera that pyvirtualcam uses on Windows." -ForegroundColor Yellow
Write-Host ""
Install-WithWinget -PackageId "OBSProject.OBSStudio" -Name "OBS Studio"
Write-Host ""
} else {
Write-Host "OBS Studio is already installed!" -ForegroundColor Green
}
Write-Host ""
# Create virtual environment
$VenvDir = Join-Path $ProjectRoot ".venv"
Write-Host "Setting up Python virtual environment..." -ForegroundColor Cyan
if (Test-Path $VenvDir) {
if ($Force) {
Write-Host "Removing existing virtual environment..." -ForegroundColor Yellow
Remove-Item -Recurse -Force $VenvDir
} else {
Write-Host "Virtual environment already exists. Updating..." -ForegroundColor Yellow
}
}
if (-not (Test-Path $VenvDir)) {
Write-Host "Creating virtual environment..." -ForegroundColor Cyan
python -m venv $VenvDir
}
# Activate virtual environment and install packages
$ActivateScript = Join-Path $VenvDir "Scripts\Activate.ps1"
Write-Host "Installing Python packages..." -ForegroundColor Cyan
& $ActivateScript
$RequirementsFile = Join-Path $ProjectRoot "pc-server\requirements.txt"
python -m pip install --upgrade pip
python -m pip install -r $RequirementsFile
Write-Host "Python packages installed successfully!" -ForegroundColor Green
Write-Host ""
# Add firewall rule
if (-not $SkipFirewall -and $isAdmin) {
Add-FirewallRule -Port $Port
Write-Host ""
}
Write-Host "======================================" -ForegroundColor Green
Write-Host "Installation Complete!" -ForegroundColor Green
Write-Host "======================================" -ForegroundColor Green
Write-Host ""
Write-Host "Next Steps:" -ForegroundColor Cyan
Write-Host ""
Write-Host "1. Start OBS Studio at least once to initialize the Virtual Camera:" -ForegroundColor White
Write-Host " - Open OBS Studio" -ForegroundColor Gray
Write-Host " - Go to Tools -> VirtualCam -> Start" -ForegroundColor Gray
Write-Host " - You can close OBS after this" -ForegroundColor Gray
Write-Host ""
Write-Host "2. Run the server:" -ForegroundColor White
Write-Host " .\pc-server\run_server.ps1 -Port $Port" -ForegroundColor Gray
Write-Host ""
Write-Host "3. For stereo side-by-side mode:" -ForegroundColor White
Write-Host " .\pc-server\run_server.ps1 -StereoMode sbs -Width 400 -Height 240" -ForegroundColor Gray
Write-Host ""
Write-Host "4. To specify a device:" -ForegroundColor White
Write-Host " .\pc-server\run_server.ps1 -Device `"OBS Virtual Camera`"" -ForegroundColor Gray
Write-Host ""
Write-Host "5. For dual devices (if you have multiple virtual cameras set up):" -ForegroundColor White
Write-Host " .\pc-server\run_server.ps1 -StereoMode dual -DualDevices `"OBS Virtual Camera,OBS Virtual Camera 2`"" -ForegroundColor Gray
Write-Host ""
if (-not $SkipFirewall -and -not $isAdmin) {
Write-Warning "Firewall rule was not added (requires admin privileges)."
Write-Warning "If you have connection issues, manually allow TCP port $Port in Windows Defender Firewall."
}
Write-Host ""
Write-Host "For more information, see README.md" -ForegroundColor Cyan
Write-Host ""