Introduction
This project was developed under two main characteristics: efficiency and
scalability.
The original example available on the Arduino IDE,
"ESP32/Camera/CameraWebServer.ino" is a great example for beginners and runs
well. However, a problem arises when I tried to escalate the project.
I decided to rebuild the entire application to adjust to my necessities,
rewrite the code, delete unnecessary parts, create a directory, and keep
single files (.cpp, .h, .html, .css, .js, etc.)
I used PlarformIO with VSCode to create the project
structure, for directory management.
I adapted the server code to run with an asynchronous web server, this
gives me the ability to handle multiple connections from different clients
simultaneously without blocking the main program flow.
The frontend was developed using responsive design to adapt to different
screens, additional for mobile devices was implemented a full-screen mode with
responses to landscape and portrait mode, a response to a pinch gesture
(two-finger gesture) action to fit the image to the screen, and an overlay
menu to modify camera options.
The code and updates can be found on my GitHub.
Prerequisites
- The code for this project is adapted to work with an ESP32-CAM AI-THINKER with an OV2460 sensor camera.
- ESP32-CAM requires a USB to UART TTL converter of 5v to upload code.
- The project was developed using Platformio IDE and VScode. An installation tutorial can be found on the RANDOM NERD TUTORIALS blog
Project Details
Circuit Diagram
![]() |
ESP32-CAM AI-THINKER connection with USB/TTL converted |
Environment configuration
The advantage of using PlatformIO over Arduino IDE is it creates a
well-structured project directory to save our files and code.
![]() |
PlatformIO-VScode project structure |
To recognize web files(.html, .js, .css, etc) the ESP32 has to use the
filesystem to save those files in the internal memory, for this purpose, we
use the LittleFS.h library that's already included on the Arduino
core for ESP32, no additional installation is required, but we have to
configure the board to use this memory space.
platformio.ini
[env:esp32cam] platform = espressif32 board = esp32cam framework = arduino monitor_speed = 115200 board_build.filesystem = littlefs ; libraries lib_deps = esphome/ESPAsyncWebServer-esphome@^3.2.2 ; explicity set MCU frecuency at 240 MHz(WiFi/BT) board_build.f_cpu = 240000000L
Microcontroller configuration
The configuration file CameraPins.h contains the GPIO declaration for
the ESP32-CAM AI-THINKER with an OV2460 sensor camera.
CameraPins.h
/** * GPIO configuration file for ESP32-CAM. * Details: * - Microcontroller model : ESP32-CAM AI-Thinker * - Camera model: OV2640 * - Resolution: 2MP */ #define PWDN_GPIO_NUM 32 #define RESET_GPIO_NUM -1 #define XCLK_GPIO_NUM 0 #define SIOD_GPIO_NUM 26 #define SIOC_GPIO_NUM 27 #define Y9_GPIO_NUM 35 #define Y8_GPIO_NUM 34 #define Y7_GPIO_NUM 39 #define Y6_GPIO_NUM 36 #define Y5_GPIO_NUM 21 #define Y4_GPIO_NUM 19 #define Y3_GPIO_NUM 18 #define Y2_GPIO_NUM 5 #define VSYNC_GPIO_NUM 25 #define HREF_GPIO_NUM 23 #define PCLK_GPIO_NUM 22 //4 for flash LED or 33 for normal LED #define LED_GPIO_NUM 4
To use another esp32-cam model or sensor camera, take a look at the original
example available on the Arduino IDE or visit the following links.
The configuration file AsyncWebCamera.h contains the camera's initial setup
and the asynchronous stream response.
The code is partially based on the AsyncWebCamera.cpp file developed by
the GitHub user me-no-dev.
AsyncWebCamera.h
#include <CameraPins.h> #include <esp_camera.h> #include <ESPAsyncWebServer.h> // this code is partialy based on AsyncWebCamera.cpp developed by user "me-no-dev" // the original code can be found here: // https://gist.github.com/me-no-dev/d34fba51a8f059ac559bf62002e61aa3 typedef struct { camera_fb_t * fb; size_t index; } camera_frame_t; // stream content configuration #define PART_BOUNDARY "123456789000000000000987654321" static const char* STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY; static const char* STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n"; static const char* STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n"; static const char * JPG_CONTENT_TYPE = "image/jpeg"; // init camera void Camera_init_cofig(){ camera_config_t config; // camera clock config.ledc_channel = LEDC_CHANNEL_0; config.ledc_timer = LEDC_TIMER_0; // camera hardware configuration config.pin_d0 = Y2_GPIO_NUM; config.pin_d1 = Y3_GPIO_NUM; config.pin_d2 = Y4_GPIO_NUM; config.pin_d3 = Y5_GPIO_NUM; config.pin_d4 = Y6_GPIO_NUM; config.pin_d5 = Y7_GPIO_NUM; config.pin_d6 = Y8_GPIO_NUM; config.pin_d7 = Y9_GPIO_NUM; config.pin_xclk = XCLK_GPIO_NUM; config.pin_pclk = PCLK_GPIO_NUM; config.pin_vsync = VSYNC_GPIO_NUM; config.pin_href = HREF_GPIO_NUM; config.pin_sccb_sda = SIOD_GPIO_NUM; config.pin_sccb_scl = SIOC_GPIO_NUM; config.pin_pwdn = PWDN_GPIO_NUM; config.pin_reset = RESET_GPIO_NUM; config.xclk_freq_hz = 20000000; // configuration for streaming //config.frame_size = FRAMESIZE_UXGA; config.pixel_format = PIXFORMAT_JPEG; config.grab_mode = CAMERA_GRAB_LATEST; // if PSRAM is present, init with UXGA resolution and higher JPEG quality // for larger pre-allocated frame buffer. if(psramFound()){ config.frame_size = FRAMESIZE_UXGA; config.fb_location = CAMERA_FB_IN_PSRAM; config.jpeg_quality = 8; config.fb_count = 2; } else { // Limit the frame size when PSRAM is not available config.frame_size = FRAMESIZE_SVGA; config.fb_location = CAMERA_FB_IN_DRAM; config.jpeg_quality = 14; config.fb_count = 1; } // initialize camera esp_err_t err = esp_camera_init(&config); if (err != ESP_OK) { Serial.printf("Camera init failed with error 0x%x", err); return; } else{ Serial.printf("Camera init successfully!\n"); } // configure camera sensor sensor_t * s = esp_camera_sensor_get(); if(config.pixel_format == PIXFORMAT_JPEG){ // drop down frame size for higher initial frame rate s->set_framesize(s, FRAMESIZE_QVGA); // image setup s->set_aec2(s,1); s->set_aec_value(s,168); s->set_agc_gain(s,5); s->set_hmirror(s,1); // register //s->set_reg(s, 0x111, 0x80, 0x80); } } // stream jpg class AsyncJpegStreamResponse: public AsyncAbstractResponse { private: camera_frame_t _frame; size_t _index; size_t _jpg_buf_len; uint8_t * _jpg_buf; uint64_t lastAsyncRequest; public: AsyncJpegStreamResponse(){ _callback = nullptr; _code = 200; _contentLength = 0; _contentType = STREAM_CONTENT_TYPE; _sendContentLength = false; _chunked = true; _index = 0; _jpg_buf_len = 0; _jpg_buf = NULL; lastAsyncRequest = 0; memset(&_frame, 0, sizeof(camera_frame_t)); } ~AsyncJpegStreamResponse(){ if(_frame.fb){ if(_frame.fb->format != PIXFORMAT_JPEG){ free(_jpg_buf); } esp_camera_fb_return(_frame.fb); } } bool _sourceValid() const { return true; } virtual size_t _fillBuffer(uint8_t *buf, size_t maxLen) override { size_t ret = _content(buf, maxLen, _index); if(ret != RESPONSE_TRY_AGAIN){ _index += ret; } return ret; } size_t _content(uint8_t *buffer, size_t maxLen, size_t index){ // available PSRAM and DRAM memory //size_t freeDRAM = heap_caps_get_free_size(MALLOC_CAP_8BIT); //size_t freePSRAM = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); if(!_frame.fb || _frame.index == _jpg_buf_len){ if(index && _frame.fb){ uint64_t end = (uint64_t)micros(); int fp = (end - lastAsyncRequest) / 1000; //log_printf("Size: %uKB, Time: %ums (%.1ffps)\n", _jpg_buf_len/1024, fp); //log_printf("PSRAM:%u, DRAM:%u\n",freePSRAM/1024, freeDRAM/1024); lastAsyncRequest = end; if(_frame.fb->format != PIXFORMAT_JPEG){ free(_jpg_buf); } esp_camera_fb_return(_frame.fb); _frame.fb = NULL; _jpg_buf_len = 0; _jpg_buf = NULL; } if(maxLen < (strlen(STREAM_BOUNDARY) + strlen(STREAM_PART) + strlen(JPG_CONTENT_TYPE) + 8)){ //log_w("Not enough space for headers"); return RESPONSE_TRY_AGAIN; } // get frame _frame.index = 0; _frame.fb = esp_camera_fb_get(); if (_frame.fb == NULL) { //log_e("Camera frame failed"); return 0; } if(_frame.fb->format != PIXFORMAT_JPEG){ unsigned long st = millis(); bool jpeg_converted = frame2jpg(_frame.fb, 80, &_jpg_buf, &_jpg_buf_len); if(!jpeg_converted){ //log_e("JPEG compression failed"); esp_camera_fb_return(_frame.fb); _frame.fb = NULL; _jpg_buf_len = 0; _jpg_buf = NULL; return 0; } //log_i("JPEG: %lums, %uB", millis() - st, _jpg_buf_len); } else { _jpg_buf_len = _frame.fb->len; _jpg_buf = _frame.fb->buf; } // send boundary size_t blen = 0; if(index){ blen = strlen(STREAM_BOUNDARY); memcpy(buffer, STREAM_BOUNDARY, blen); buffer += blen; } // send header size_t hlen = sprintf((char *)buffer, STREAM_PART, JPG_CONTENT_TYPE, _jpg_buf_len); buffer += hlen; // send frame hlen = maxLen - hlen - blen; if(hlen > _jpg_buf_len){ maxLen -= hlen - _jpg_buf_len; hlen = _jpg_buf_len; } memcpy(buffer, _jpg_buf, hlen); _frame.index += hlen; return maxLen; } size_t available = _jpg_buf_len - _frame.index; if(maxLen > available){ maxLen = available; } memcpy(buffer, _jpg_buf+_frame.index, maxLen); _frame.index += maxLen; return maxLen; } };
The main.cpp file contains all the necessary code to run an asynchronous web
server and handle the client's requests.
main.cpp
#include <Arduino.h> // esp32cam with async web server #include <AsyncWebCamera.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> // wifi credentials (create your own file on include/Secrets.h) #include <Secrets.h> // wifi library #include <WiFi.h> // upload files to filesystem #include <LittleFS.h> //Web server credentials const char* ssid = WIFI_SSID; const char* password = WIFI_PASSWORD; AsyncWebServer server(80); //LittleFs filesystem #define FORMAT_LITTLEFS_IF_FAILED true //Esp32-cam internal led int led_channel = 2; int led_resolution = 8; int led_frequency = 980; int led_duty = 0; int max_intensity = 255; /*FUNCTION DECLARATION------------------------------------------------------------------------------------------------------*/ bool initWiFi(); void notFound(AsyncWebServerRequest *request); void StreamJpg(AsyncWebServerRequest *request); void SetCameraVar(AsyncWebServerRequest *request); void GetCameraStatus(AsyncWebServerRequest *request); void SetXclkValue(AsyncWebServerRequest *request); void SetupCameraLed(); /*--------------------------------------------------------------------------------------------------------------------------*/ /*VOID SETUP CONFIGURATION--------------------------------------------------------------------------------------------------*/ void setup() { Serial.begin(115200); // initialize camera led SetupCameraLed(); // initialize camera (ESP32-CAM AI-Thinker configuration) Camera_init_cofig(); // Mount LittleFS filesystem if(!LittleFS.begin(FORMAT_LITTLEFS_IF_FAILED)){ Serial.println("LITTLEFS Mount Failed"); return; } // initialize wifi initWiFi(); //++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ /* // normal web files server.serveStatic("/static/css/style.css", LittleFS, "/static/css/style.css"); server.serveStatic("/static/js/main.js", LittleFS, "/static/js/main.js"); server.serveStatic("/static/icons/", LittleFS, "/static/icons/"); server.on("/", HTTP_ANY, [](AsyncWebServerRequest *request){ request->send(LittleFS,"/stream.html","text/html", false); }); */ //++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ //++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ // compressed web files server.on("/static/js/main.js", HTTP_GET, [](AsyncWebServerRequest *request) { AsyncWebServerResponse *response = request->beginResponse(LittleFS, "/static/js/main.js.gz", "application/javascript"); response->addHeader("Content-Encoding", "gzip"); request->send(response); }); server.on("/static/css/style.css", HTTP_GET, [](AsyncWebServerRequest *request) { AsyncWebServerResponse *response = request->beginResponse(LittleFS, "/static/css/style.css.gz", "text/css"); response->addHeader("Content-Encoding", "gzip"); request->send(response); }); server.serveStatic("/static/icons/", LittleFS, "/static/icons/").setCacheControl("max-age=300"); //server.serveStatic("/", LittleFS, "/").setDefaultFile("stream.html.gz").setCacheControl("max-age=200"); server.on("/", HTTP_ANY, [](AsyncWebServerRequest *request){ AsyncWebServerResponse *response = request->beginResponse(LittleFS, "/stream.html.gz", "text/html"); response->addHeader("Content-Encoding", "gzip"); request->send(response); }); //++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ // Handle URLs server.onNotFound(notFound); server.on("/stream", HTTP_GET, StreamJpg); server.on("/control", HTTP_GET, SetCameraVar); server.on("/status", HTTP_GET, GetCameraStatus); server.on("/xclk", HTTP_GET, SetXclkValue); server.begin(); } /*--------------------------------------------------------------------------------------------------------------------------*/ /*LOOP----------------------------------------------------------------------------------------------------------------------*/ void loop(){ //camera led control ledcWrite(led_channel,led_duty); } /*--------------------------------------------------------------------------------------------------------------------------*/ /*FUNCTIONS-----------------------------------------------------------------------------------------------------------------*/ // URL not found void notFound(AsyncWebServerRequest *request) { request->send(404, "text/plain", "Not found"); } // stream jpg images over async web server void StreamJpg(AsyncWebServerRequest *request){ AsyncJpegStreamResponse *response = new AsyncJpegStreamResponse(); if(!response){ // not implemented : server not support the functionality request->send(501); return; } response->addHeader("Access-Control-Allow-Origin", "*"); response->addHeader("X-Framerate", "60"); request->send(response); } // set xclk(external clock) value. void SetXclkValue(AsyncWebServerRequest *request){ if(!request->hasArg("xclk")){ request->send(404, "text/plain", "Not found"); return; } int xclk = atoi(request->arg("xclk").c_str()); sensor_t * s = esp_camera_sensor_get(); if(s == NULL){ // not implemented : server not support the functionality request->send(501); return; } // xclk value in MHz, default 20MHz int res = s->set_xclk(s,LEDC_TIMER_0,xclk); AsyncWebServerResponse * response = request->beginResponse(200); response->addHeader("Access-Control-Allow-Origin", "*"); request->send(response); } // change camera sensor values void SetCameraVar(AsyncWebServerRequest *request){ if(!request->hasArg("var") || !request->hasArg("val")){ request->send(404,"text/plain", "Not found"); return; } String var = request->arg("var"); const char * variable = var.c_str(); int val = atoi(request->arg("val").c_str()); sensor_t * s = esp_camera_sensor_get(); if(s == NULL){ // not implemented : server not support the functionality request->send(501); return; } int res = 0; if(!strcmp(variable, "framesize")) res = s->set_framesize(s, (framesize_t)val); else if(!strcmp(variable, "quality")) res = s->set_quality(s, val); else if(!strcmp(variable, "contrast")) res = s->set_contrast(s, val); else if(!strcmp(variable, "brightness")) res = s->set_brightness(s, val); else if(!strcmp(variable, "saturation")) res = s->set_saturation(s, val); else if(!strcmp(variable, "gainceiling")) res = s->set_gainceiling(s, (gainceiling_t)val); else if(!strcmp(variable, "colorbar")) res = s->set_colorbar(s, val); else if(!strcmp(variable, "awb")) res = s->set_whitebal(s, val); else if(!strcmp(variable, "agc")) res = s->set_gain_ctrl(s, val); else if(!strcmp(variable, "aec")) res = s->set_exposure_ctrl(s, val); else if(!strcmp(variable, "hmirror")) res = s->set_hmirror(s, val); else if(!strcmp(variable, "vflip")) res = s->set_vflip(s, val); else if(!strcmp(variable, "awb_gain")) res = s->set_awb_gain(s, val); else if(!strcmp(variable, "agc_gain")) res = s->set_agc_gain(s, val); else if(!strcmp(variable, "aec_value")) res = s->set_aec_value(s, val); else if(!strcmp(variable, "aec2")) res = s->set_aec2(s, val); else if(!strcmp(variable, "dcw")) res = s->set_dcw(s, val); else if(!strcmp(variable, "bpc")) res = s->set_bpc(s, val); else if(!strcmp(variable, "wpc")) res = s->set_wpc(s, val); else if(!strcmp(variable, "raw_gma")) res = s->set_raw_gma(s, val); else if(!strcmp(variable, "lenc")) res = s->set_lenc(s, val); else if(!strcmp(variable, "special_effect")) res = s->set_special_effect(s, val); else if(!strcmp(variable, "wb_mode")) res = s->set_wb_mode(s, val); else if(!strcmp(variable, "ae_level")) res = s->set_ae_level(s, val); else if(!strcmp(variable, "led_intensity")){ led_duty = val; } else { request->send(404); return; //log_e("unknown setting %s", var.c_str()); } //log_d("Got setting %s with value %d. Res: %d", var.c_str(), val, res); AsyncWebServerResponse * response = request->beginResponse(200); response->addHeader("Access-Control-Allow-Origin", "*"); request->send(response); }; // get camera sensor values void GetCameraStatus(AsyncWebServerRequest *request){ static char json_response[1024]; sensor_t * s = esp_camera_sensor_get(); if(s == NULL){ // not implemented : server not support the functionality request->send(501); return; } char * p = json_response; *p++ = '{'; // register only for 0v2640 sensor camera p+=sprintf(p, "\"0xd3\":%u,", s->get_reg(s, 0xd3, 0xFF)); p+=sprintf(p, "\"0x111\":%u,", s->get_reg(s, 0x111, 0xFF)); p+=sprintf(p, "\"0x132\":%u,", s->get_reg(s, 0x132, 0xFF)); // external clock p+=sprintf(p, "\"xclk\":%u,", s->xclk_freq_hz / 1000000); // image format (jpg = 4) p+=sprintf(p, "\"pixformat\":%u,", s->pixformat); // image quality p+=sprintf(p, "\"framesize\":%u,", s->status.framesize); p+=sprintf(p, "\"quality\":%u,", s->status.quality); // sensor camera p+=sprintf(p, "\"brightness\":%d,", s->status.brightness); p+=sprintf(p, "\"contrast\":%d,", s->status.contrast); p+=sprintf(p, "\"saturation\":%d,", s->status.saturation); p+=sprintf(p, "\"special_effect\":%u,", s->status.special_effect); p+=sprintf(p, "\"wb_mode\":%u,", s->status.wb_mode); p+=sprintf(p, "\"awb\":%u,", s->status.awb); p+=sprintf(p, "\"awb_gain\":%u,", s->status.awb_gain); p+=sprintf(p, "\"aec\":%u,", s->status.aec); p+=sprintf(p, "\"aec2\":%u,", s->status.aec2); p+=sprintf(p, "\"ae_level\":%d,", s->status.ae_level); p+=sprintf(p, "\"aec_value\":%u,", s->status.aec_value); p+=sprintf(p, "\"agc\":%u,", s->status.agc); p+=sprintf(p, "\"agc_gain\":%u,", s->status.agc_gain); p+=sprintf(p, "\"gainceiling\":%u,", s->status.gainceiling); p+=sprintf(p, "\"bpc\":%u,", s->status.bpc); p+=sprintf(p, "\"wpc\":%u,", s->status.wpc); p+=sprintf(p, "\"raw_gma\":%u,", s->status.raw_gma); p+=sprintf(p, "\"lenc\":%u,", s->status.lenc); p+=sprintf(p, "\"hmirror\":%u,", s->status.hmirror); p+=sprintf(p, "\"vflip\":%u,", s->status.vflip); p+=sprintf(p, "\"dcw\":%u,", s->status.dcw); p+=sprintf(p, "\"colorbar\":%u", s->status.colorbar); // microcontroller internal led p+=sprintf(p, ",\"led_intensity\":%u", led_duty); *p++ = '}'; *p++ = 0; AsyncWebServerResponse * response = request->beginResponse(200, "application/json", json_response); response->addHeader("Access-Control-Allow-Origin", "*"); request->send(response); } // esp32-cam internal led configuration void SetupCameraLed(){ pinMode(LED_GPIO_NUM,OUTPUT); ledcSetup(led_channel, led_frequency, led_resolution); ledcAttachPin(LED_GPIO_NUM, led_channel); } // connect to wifi bool initWiFi(){ // web server as Station mode: // we can request information from the internet WiFi.mode(WIFI_STA); WiFi.begin(ssid,password); if (WiFi.waitForConnectResult() != WL_CONNECTED) { Serial.printf("WiFi Failed!\n"); } else{ Serial.print("WiFi connected succesfully!"); Serial.println(WiFi.localIP()); } return true; } /*--------------------------------------------------------------------------------------------------------------------------*/
The web files (.html, .js, .css) make possible the client-side interaction
with the server. I used responsive design to make a web page more user-friendly
and capable of adapting to different screens.
stream.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <!--css files--> <link rel="stylesheet" type="text/css" href="./static/css/style.css"> <!--js files--> <script defer src="./static/js/main.js"></script> <title>Video streaming with responsive design</title> </head> <body> <!--stream images--> <section> <div id="main-container"> <!--navigation menu--> <div id="navbar"> <div id="menu-button-container"> <button type="button" class="" id="btn-menu"><img src="./static/icons/menu.svg"></button> </div> <!--fs:full screen--> <div id="fs-button-container"> <button type="button" class="" id="btn-screen" hidden><span id="fs-status" hidden>normal</span><img id="fs-icon" src="./static/icons/full-screen.svg"></button> </div> </div> <!--ss: star/stop stream--> <div id="ss-button-container"> <button type="button" class="" id="btn-stream">start stream</button> </div> <!--content loading message--> <div id="loading-container" hidden> <p>Stream content not available</p> </div> <!--content loaded--> <div id="stream-container"> <img id="stream" src="" hidden crossorigin > </div> <!--camera configuration options--> <div id="menu-opt-container" hidden> <div id="menu-opt-header"> <h3>Camera menu options</h3> <button type="button" class="btn-size btn-style" id="btn-close"><img src="./static/icons/close.svg"></button> </div> <div id="menu-opt-list"> <!--camera clock signal--> <div class="input-group" id="set-xclk-group"> <label for="set-xclk">XCLK MHz</label> <div class="text"> <input id="xclk-value" type="text" minlength="1" maxlength="2" size="2" value="20"> </div> <button class="inline-button" id="set-xclk">Set</button> </div> <!--frame size--> <div class="input-group" id="framesize-group"> <label for="framesize">Resolution</label> <select id="framesize" class="default-action"> <option value="12">SXGA(1280x1024)</option> <option value="11">HD(1280x720)</option> <option value="8">VGA(640x480)</option> <option value="5">QVGA(320x240)</option> <option value="4">240x240</option> </select> </div> <!--image quality--> <div class="input-group" id="quality-group"> <label for="quality">Quality</label> <div class="slider-container"> <input type="range" id="quality" min="4" max="63" value="8" class="default-action"> <output id="quality-value"></output> </div> </div> <!--image brightness--> <div class="input-group" id="brightness-group"> <label for="brightness">Brightness</label> <div class="slider-container"> <input type="range" id="brightness" min="-2" max="2" value="0" class="default-action"> <output id="brightness-value"></output> </div> </div> <!--image contrast--> <div class="input-group" id="contrast-group"> <label for="contrast">Contrast</label> <div class="slider-container"> <input type="range" id="contrast" min="-2" max="2" value="0" class="default-action"> <output id="contrast-value"></output> </div> </div> <!--image saturation--> <div class="input-group" id="saturation-group"> <label for="saturation">Saturation</label> <div class="slider-container"> <input type="range" id="saturation" min="-2" max="2" value="0" class="default-action"> <output id="saturation-value"></output> </div> </div> <!--image effect--> <div class="input-group" id="special_effect-group"> <label for="special_effect">Special Effect</label> <select id="special_effect" class="default-action"> <option value="0" selected="selected">No Effect</option> <option value="2">Grayscale</option> </select> </div> <!--image horizontal mirror--> <div class="input-group" id="hmirror-group"> <label for="hmirror">Image Mirror</label> <div class="switch"> <input id="hmirror" type="checkbox" class="default-action" checked="checked"> <label class="slider" for="hmirror"></label> </div> </div> <!--image vertical flip--> <div class="input-group" id="vflip-group"> <label for="vflip">image Flip</label> <div class="switch"> <input id="vflip" type="checkbox" class="default-action" checked="checked"> <label class="slider" for="vflip"></label> </div> </div> <!--led intensity--> <div class="input-group" id="led-group"> <label for="led_intensity">LED Intensity</label> <div class="slider-container"> <input type="range" id="led_intensity" min="0" max="255" value="0" class="default-action"> <output id="led-value"></output> </div> </div> </div> </div> </div> </section> </body> </html>
style.css
/* * stream jpeg images */ section{ /*delete default space in the tag*/ margin: 0; position: relative; } #main-container{ /*stream container in the middle of the screen*/ height: 100vh; display: flex; justify-content: center; } #stream-container{ position: absolute; top:4em; background-color: rgb(79, 77, 77); width: calc(100vw - 80px); height: calc(0.70*(100vw - 80px)); display: flex; align-items: center; justify-content: center; } #stream{ /*image fits into the container*/ max-width: 100%; max-height: 100%; } #navbar{ width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 0px 10px 0px 10px; position: absolute; top:10px; z-index: 1; } #ss-button-container{ z-index:1; } #btn-menu, #btn-screen,#btn-close{ border: none; background-color: transparent; width: 44px; height: 44px; } #btn-stream{ border: solid green 2px; border-radius: 5%; background-color: transparent; color: black; height: 44px; width: 120px; position: absolute; top:10px; left:50%; transform:translateX(-50%); } #btn-menu > img{ /*icon white*/ /*filter: invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%);*/ /*icon black*/ filter: invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%); } #btn-screen > img{ /*icon black*/ filter: invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%); } #loading-container{ position: absolute; top: 4.5em; z-index: 1; color: white; } /* * camera menu options */ #menu-opt-container{ /*container position*/ position: absolute; top:80px; width:330px; /*center container*/ left: 50%; transform: translateX(-50%); /*style container*/ border: solid 1px gray; background: rgba(128,128,128,.2); color:black; border-radius: 3%; padding: 10px 15px 10px 15px; z-index: 1; } #menu-opt-container > #menu-opt-list> .input-group > label{ width:110px; } #menu-opt-header{ display: flex; justify-content: space-between; margin-bottom: 15px; border-bottom: solid 1px gray; } #framesize,#special_effect,.slider-container{ height: 35px; width: 180px; } #framesize,#special_effect,#hmirror,#vflip{ /*background: rgba(224, 224, 224, 0.1);*/ background:white; border: none; } /*check buttons as switch style*/ .switch { position: relative; display: inline-block; width: 70px; height: 30px; margin: 5px 0px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: grey; transition: 0.4s; border-radius: 22px; } .slider:before { position: absolute; content: ""; height: 22px; width: 22px; left: 4px; bottom: 4px; background-color: white; transition: 0.4s; border-radius: 50%; } input:checked + .slider { background-color: #0d6efd; } input:checked + .slider:before { transform: translateX(38px); } .input-group{ padding-top: 4px; padding-bottom: 4px; display:flex; align-items: center; } .slider-container{ display: flex; align-items: center; justify-content: space-between; } #btn-close{ position: relative; bottom: 5px; left: 0px; } #set-xclk{ position:relative; left: 5px; border: none; border-radius: 5px; background-color: #0d6efd; color: white; } #xclk-value,#set-xclk{ width: 60px; height: 30px; margin: 5px 0px; border-radius:20px; border:none; text-align: center; } /*arrow icon and left border on select options*/ #framesize,#special_effect{ border-radius: 20px; padding-left: 8px; -moz-appearance:none; /* Firefox */ -webkit-appearance:none; /* Safari and Chrome */ appearance:none; background-image: linear-gradient(45deg, transparent 50%, gray 50%), linear-gradient(135deg, gray 50%, transparent 50%), linear-gradient(to right, #ccc, #ccc); background-position: calc(100% - 20px) calc(1em + 0px), calc(100% - 15px) calc(1em + 0px), calc(100% - 2.2em) 0.3em; background-size: 5px 5px, 5px 5px, 1px 1.5em; background-repeat: no-repeat; } @media only screen and (max-height:430px) and (orientation:landscape){ #stream-container{ width: calc(100vh - 50px); height: calc(0.70*(100vh - 50px)); } #stream{ width: 100%; height: 100%; object-fit: cover; } #menu-opt-list{ height: calc(0.4*100vh); overflow-y:scroll; } #menu-opt-container{ width:360px; height: auto; /*center container*/ left: 50%; transform: translateX(-50%); } } @media only screen and (min-height:431px) and (max-height:800px) and (orientation:landscape){ #stream-container{ width: calc(100vh - 82px); height: calc(0.70*(100vh - 82px)); } #stream{ width: 100%; height: 100%; object-fit: cover; } #menu-opt-list{ height: calc(0.55*100vh); overflow-y:scroll; } #menu-opt-container{ width:360px; /*center container*/ left: 50%; transform: translateX(-50%); } } @media only screen and (min-height:801px){ #stream-container{ width: calc(100vh - 94px); height: calc(0.70*(100vh - 94px)); } #stream{ width: 100%; height: 100%; object-fit: cover; } #menu-opt-container{ width:360px; } } @media (orientation: portrait){ #main-container{ align-items:center; } #stream-container{ width: calc(100vw - 80px); height: calc(0.70*(100vw - 80px)); } #stream{ width: 100%; height: 100%; object-fit: cover; } }
main.js
/** * Variable declaration * --------------------------------------------------------------------- */ // image variables from html const mainContainer = document.getElementById('main-container'); const view = document.getElementById('stream'); const streamContainer = document.getElementById('stream-container'); // buttons from html const btnStream = document.getElementById('btn-stream'); const btnFullScreen = document.getElementById('btn-screen'); const btnMenu = document.getElementById('btn-menu'); const btnClose = document.getElementById('btn-close'); const btnXclk = document.getElementById('set-xclk'); // loading message var loadingMsg = document.getElementById('loading-container'); // full screen var fullScreenStatus = document.getElementById('fs-status'); var fullScreenIcon = document.getElementById('fs-icon'); // menu options const xclkValue = document.getElementById('xclk-value'); const menuContainer = document.getElementById('menu-opt-container'); const framesizeSelect = document.getElementById('framesize'); const qualityInput = document.getElementById('quality'); const qualityValue = document.getElementById('quality-value'); const brightnessInput = document.getElementById('brightness'); const brightnessValue = document.getElementById('brightness-value'); const contrastInput = document.getElementById('contrast'); const contrastValue = document.getElementById('contrast-value'); const saturationInput = document.getElementById('saturation'); const saturationValue = document.getElementById('saturation-value'); const ledIntensity = document.getElementById('led_intensity'); const ledValue = document.getElementById('led-value'); // pinch zoom gesture variables // code partially based on : // github.com/mdn/dom-examples/blob/main/pointerevents/Pinch_zoom_gestures.html // // Global variables to cache event state var evCache = new Array(); var prevDiff = -1; //---------------------------------------------------------------------- /** * Event listener * --------------------------------------------------------------------- */ // start stop stream with button document.addEventListener('DOMContentLoaded',()=>{ // show initial message over stream area loadingMsg.hidden = false; //set camera initial values cameraInitialValues(); }) // adjust screen with orientation if ('orientation' in screen){ screen.addEventListener('change',()=>{ screenStatus(fullScreenStatus.innerHTML); }); } // return to normal mode(close full screen) // if back button is pressed instead of full screen button document.addEventListener('fullscreenchange',()=>{ // exit full screen if(document.fullscreenElement === null){ fullScreenIcon.src = './static/icons/full-screen.svg'; exitFullscreen(); fullScreenStatus.innerHTML = 'normal'; screenStatus(fullScreenStatus.innerHTML); } }) // menu options // open/close menu options btnMenu.addEventListener('click',()=>{ menuContainer.hidden = false; menuContainer.style.zIndex = 1000; view.style.zIndex = 0; //console.log("menu opened"); }) btnClose.addEventListener('click',()=>{ menuContainer.hidden = true; //console.log("menu closed") }) // shows image quality value qualityInput.addEventListener("input",(event)=>{ qualityValue.textContent = event.target.value; }) // shows image brightness value brightnessInput.addEventListener("input",(event)=>{ brightnessValue.textContent = event.target.value; }) // shows image contrast value contrastInput.addEventListener("input",(event)=>{ contrastValue.textContent = event.target.value; }) // shows image saturation value saturationInput.addEventListener("input",(event)=>{ saturationValue.textContent = event.target.value; }) // shows led intensity value ledIntensity.addEventListener("input",(event)=>{ ledValue.textContent = event.target.value; }) // change image configuration on tag change document.querySelectorAll('.default-action').forEach((el)=>{ el.addEventListener('change',()=>{ updateConfig(el); }) }) // change xclk on button click btnXclk.addEventListener('click',()=>{ let xclkValue = document.getElementById('xclk-value').value; setXclkValue(xclkValue); }) // start/stop stream btnStream.addEventListener('click',()=>{ const streamEnabled = btnStream.innerHTML === 'stop stream'; if(streamEnabled){ stopStream(); loadingMsg.hidden = false; btnFullScreen.hidden = true; if(fullScreenStatus.innerHTML === 'full'){ screenStatus(fullScreenStatus.innerHTML); fullScreenStatus.innerHTML = 'normal'; fullScreenIcon.src = './static/icons/full-screen.svg'; exitFullscreen(); } } else{ startStream(); loadingMsg.hidden = true; btnFullScreen.hidden = false; } }) // enter/exit full screen mode btnFullScreen.addEventListener('click',()=>{ if(fullScreenStatus.innerHTML === 'normal'){ fullScreenStatus.innerHTML = 'full'; fullScreenIcon.src = './static/icons/exit-full-screen.svg'; screenStatus(fullScreenStatus.innerHTML); requestFullscreen(mainContainer); } else if(fullScreenStatus.innerHTML === 'full'){ fullScreenStatus.innerHTML = 'normal'; fullScreenIcon.src = './static/icons/full-screen.svg'; screenStatus(fullScreenStatus.innerHTML); exitFullscreen(); } }) //---------------------------------------------------------------------- /** * User functions * --------------------------------------------------------------------- */ // pinch zoom gesture // code partially based on : // github.com/mdn/dom-examples/blob/main/pointerevents/Pinch_zoom_gestures.html // function pointerdownHandler(ev) { // The pointerdown event signals the start of a touch interaction. // This event is cached to support 2-finger gestures evCache.push(ev); } function pointermoveHandler(ev) { // This function implements a 2-pointer horizontal pinch/zoom gesture. // // If the distance between the two pointers has increased (zoom in), // the taget element (image) fit into the screen and if the // distance is decreasing (zoom out), the target element (image) return to // its original size // // Find this event in the cache and update its record with this event for (var i = 0; i < evCache.length; i++) { if (ev.pointerId == evCache[i].pointerId) { evCache[i] = ev; break; } } // If two pointers are down, check for pinch gestures if (evCache.length == 2) { // Calculate the distance between the two pointers var curDiff = Math.sqrt( Math.pow(evCache[1].clientX - evCache[0].clientX, 2) + Math.pow(evCache[1].clientY - evCache[0].clientY, 2) ); if (prevDiff > 0) { if (curDiff > prevDiff) { // The distance between the two pointers has increased //console.log("increase"); ev.target.style.width = "100%"; ev.target.style.height = "100%"; ev.target.style.objectFit = "cover"; } if (curDiff < prevDiff) { // The distance between the two pointers has decreased //console.log("decrease"); ev.target.style.objectFit = "initial"; if ('orientation' in screen && screen.orientation.type === 'portrait-primary'){ ev.target.style.width = "100%"; ev.target.style.height = "auto"; } else{ ev.target.style.width = "auto"; ev.target.style.height = "100%"; } } } // Cache the distance for the next move event prevDiff = curDiff; } } function pointerupHandler(ev) { // Remove this pointer from the cache and reset the target's removeEvent(ev); // If the number of pointers down is less than two then reset diff tracker if (evCache.length < 2) prevDiff = -1; } function removeEvent(ev) { // Remove this event from the target's cache for (var i = 0; i < evCache.length; i++) { if (evCache[i].pointerId == ev.pointerId) { evCache.splice(i, 1); break; } } } const setupFullScreenEventListeners = (add) => { const action = add ? 'addEventListener' : 'removeEventListener'; view[action]("pointerdown", pointerdownHandler); view[action]("pointermove", pointermoveHandler); view[action]("pointerup", pointerupHandler); view[action]("pointercancel", pointerupHandler); view[action]("pointerout", pointerupHandler); view[action]("pointerleave", pointerupHandler); }; // full screen mode function requestFullscreen(element) { if (element.requestFullscreen) { element.requestFullscreen().catch(err => { //console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`); }); } else if (element.mozRequestFullScreen) { // Firefox element.mozRequestFullScreen().catch(err => { //console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`); }); } else if (element.webkitRequestFullscreen) { // Chrome, Safari, and Opera element.webkitRequestFullscreen().catch(err => { //console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`); }); } else if (element.msRequestFullscreen) { // IE/Edge element.msRequestFullscreen().catch(err => { //console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`); }); } } //exit full screen mode function exitFullscreen() { if (document.exitFullscreen) { document.exitFullscreen().catch(err => { //console.error(`Error attempting to exit full-screen mode: ${err.message} (${err.name})`); }); } else if (document.mozCancelFullScreen) { // Firefox document.mozCancelFullScreen().catch(err => { //console.error(`Error attempting to exit full-screen mode: ${err.message} (${err.name})`); }); } else if (document.webkitExitFullscreen) { // Chrome, Safari, and Opera document.webkitExitFullscreen().catch(err => { //console.error(`Error attempting to exit full-screen mode: ${err.message} (${err.name})`); }); } else if (document.msExitFullscreen) { // IE/Edge document.msExitFullscreen().catch(err => { //console.error(`Error attempting to exit full-screen mode: ${err.message} (${err.name})`); }); } } // simple stream function function startStream(){ // start stream var streamUrl = document.location.origin; btnStream.innerHTML = 'stop stream'; btnStream.style.border = 'solid red 2px'; view.hidden = false; view.src = `${streamUrl}/stream` // upload an image to the data folder to use instead of the stream for debugging purposes //view.src = "./test.jpg"; } // stop stream function stopStream(){ // stop stream btnStream.innerHTML = 'start stream'; btnStream.style.border = 'solid green 2px'; window.stop(); view.hidden = true; view.src=""; } // landscape/portrait image container function normalLandscape(){ var landscapeMin = window.matchMedia('only screen and (max-height:430px) and (orientation:landscape)'); var lanscaspeMid = window.matchMedia('only screen and (min-height:431px) and (max-height:800px) and (orientation:landscape)'); var landscapeMax = window.matchMedia('only screen and (min-height:801px)'); streamContainer.style.top = '4em'; streamContainer.style.width = 'calc(100vh - 50px)'; streamContainer.style.height = 'calc(0.70*(100vh - 50px))'; if (landscapeMin.matches){ streamContainer.style.width = 'calc(100vh - 50px)'; streamContainer.style.height = 'calc(0.70*(100vh - 50px))'; } else if(lanscaspeMid.matches){ streamContainer.style.width = 'calc(100vh - 82px)'; streamContainer.style.height = 'calc(0.70*(100vh - 82px))'; } else if(landscapeMax.matches){ streamContainer.style.width = 'calc(100vh - 94px)'; streamContainer.style.height = 'calc(0.70*(100vh - 94px))'; } view.style.width = '100%'; view.style.height = '100%'; view.style.objectFit = 'cover'; btnStream.style.color = 'black'; btnFullScreen.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)'; btnMenu.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)'; btnClose.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)'; menuContainer.style.color = 'black'; //console.log("normal landscape"); } function normalPortrait(){ streamContainer.style.width = 'calc(100vw - 80px)'; streamContainer.style.height = 'calc(0.70*(100vw - 80px))'; streamContainer.style.top = '4em'; view.style.width = '100%'; view.style.height = '100%'; view.style.objectFit = 'cover'; btnStream.style.color = 'black'; btnFullScreen.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)'; btnMenu.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)'; btnClose.style.filter = 'invert(0%) sepia(0%) saturate(18%) hue-rotate(293deg) brightness(102%) contrast(105%)'; menuContainer.style.color = 'black'; //console.log("normal portrait"); } function fullLandscape(){ streamContainer.style.width = '100vw'; streamContainer.style.height = '100vh'; streamContainer.style.top = '0em'; view.style.height = '100vh'; view.style.width = 'auto'; btnStream.style.color = 'white'; btnFullScreen.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)'; btnMenu.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)'; btnClose.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)'; menuContainer.style.color = 'white'; //console.log("full landscape"); } function fullPortrait(){ streamContainer.style.width = '100vw'; streamContainer.style.height = '100vh'; streamContainer.style.top = '0em'; view.style.width = '100vw'; view.style.height = 'auto'; btnStream.style.color = 'white'; btnFullScreen.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)'; btnMenu.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)'; btnClose.style.filter = 'invert(100%) sepia(3%) saturate(549%) hue-rotate(219deg) brightness(119%) contrast(100%)'; menuContainer.style.color = 'white'; //console.log("full portrait"); } // control actions on screen rotation function screenStatus(status){ if (status === 'full'){ setupFullScreenEventListeners(true); if(screen.orientation.type.startsWith('landscape')){ fullLandscape(); } else if(screen.orientation.type.startsWith('portrait')){ fullPortrait(); } } else if (status === 'normal'){ setupFullScreenEventListeners(false); if(screen.orientation.type.startsWith('landscape')){ normalLandscape(); } else if(screen.orientation.type.startsWith('portrait')){ normalPortrait(); } } } // change image values function updateConfig(el){ let value; switch(el.type){ case 'checkbox': value = el.checked ? 1 : 0; break; case 'range': case 'select-one': value = el.value; break; case 'button': case 'submit': value = '1'; break; default: return; } const baseHost = document.location.origin; const query = `${baseHost}/control?var=${el.id}&val=${value}`; fetch(query) .then(response=>{ //console.log(`request to ${query} finished, status: ${response.status}`) }) } // update menu values in html function updateValue(el,value,updateRemote){ updateRemote = updateRemote == null ? true : updateRemote; let initialValue; if(el.type === 'checkbox'){ initialValue = el.checked; // make sure to explicity convet to a boolean value = !!value; el.checked = value; } else{ initialValue = el.value el.value = value } if(updateRemote && initialValue !== value){ updateConfig(el); } } // get camera initial values function cameraInitialValues(){ const baseHost = document.location.origin; fetch(`${baseHost}/status`) .then(response=>{ return response.json(); }) .then(state=>{ //console.log(state); document.querySelectorAll('.default-action').forEach(el=>{ // update states of buttons, sliders and check buttons updateValue(el,state[el.id],false); //update html values brightnessValue.textContent = brightnessInput.value; ledValue.textContent = ledIntensity.value; saturationValue.textContent = saturationInput.value; contrastValue.textContent = contrastInput.value; qualityValue.textContent = qualityInput.value; }) }) } // change external clock values function setXclkValue(value){ const baseHost = document.location.origin; const query = `${baseHost}/xclk?xclk=${value}`; fetch(query) .then(response =>{ if (response.status !== 200){ //console.log("Error["+response.status+"]:"+response.statusText); } else{ //console.log(`request to ${query} finished, status: ${response.status}`) return response.text(); } }) .then(data=>{ //console.log(data); }) .catch(error=>{ //console.log("Error[-1]:"+error); }) } //----------------------------------------------------------------------
User interaction for mobile devices:
- Full-screen mode for landscape and portrait mode
- Pinch gesture (two-finger gesture) action to fit the image to the screen in full-screen landscape mode
- Return to normal mode with the mobile device's back button.
- Overlay menu for camera configuration
User interaction for desktop devices:
- Full-screen mode
- Overlay menu for camera configuration
Comments
Post a Comment