Skip to main content

Building a Screen and Webcam Recorder Inside Oracle APEX

Oracle APEXJavaScriptMediaRecorder APIDebugging Log

Building a Screen and Webcam Recorder Inside Oracle APEX

A real walkthrough of what worked, what broke, and how I fixed it, step by step.

I have written separately about a screen recorder in APEX in Part 1 and a multi stream webcam recorder in APEX in Part 2. A few readers asked me the same question after that. Can both run together, at the same time, in one recording. So I sat down to actually build it, and this post is basically my notes from that whole process, including the parts that did not work on the first try.

If you just came here to grab the working code, it is all below with copy buttons on every block. But I am also going to walk through the mistakes because I think that is the part that actually helps someone stuck on the same problem. If you have not read the earlier two posts, they are worth a quick look first since this build stands on top of both of them.


The First Idea That Did Not Work

My first thought was simple. Just start two MediaRecorder instances, one for the screen stream and one for the webcam stream, and save two files. Technically that works, but it is not what people actually want when they say record screen and webcam together. They want one video, with the webcam showing as a small box over the screen recording, like you see in tutorial videos on YouTube.

So two separate files was not the answer. I needed one merged video.

I tried just overlaying the webcam video element on top of the screen video element using CSS position absolute and recording the screen stream alone, hoping the browser would flatten both layers into one video. It did not work. MediaRecorder only records the stream you give it, not whatever is visually sitting on top of it in the DOM. The webcam box was visible on my screen but never showed up in the recorded file.

The Approach That Finally Worked

After some digging, I found the right method. Instead of recording either the screen stream or the webcam stream directly, you draw both of them onto a canvas element, frame by frame, using requestAnimationFrame. The screen video gets drawn full size as the background, and the webcam video gets drawn as a small clipped box in the corner. Then you record the canvas itself using canvas.captureStream, not the original streams.

This is the part that finally clicked for me. The canvas becomes the new source of truth. Whatever you draw on it is what gets recorded, so if both videos are being painted onto it every frame, the recording naturally has both in it, permanently merged into a single video.

Audio needed a small extra step too. Screen sharing gives you one audio track and the webcam gives you another, usually your mic. I just pushed both of those tracks into the canvas stream manually before starting the recorder, so the final video has both audio sources mixed in.

Setting Up the Page in APEX

The page itself is a plain Static Content region. Nothing fancy on the APEX side, all the real work happens in the browser through JavaScript. Here is the HTML structure I ended up with.

HTML
<div id="screenWebcamRec" class="t-Form--stretchInputs">

  <div class="rec-panel">
    <div class="rec-panel-header">
      <h3>Screen and Webcam Recorder</h3>
      <span id="recBadge" class="rec-badge" style="display:none;">
        <span class="rec-dot"></span> REC
      </span>
    </div>

    <div class="video-card">
      <label class="t-Form-label">Live / Recorded Preview</label>
      <div class="video-player">
        <canvas id="mixCanvas" width="1280" height="720"></canvas>
        <video id="webcamRaw" autoplay muted playsinline style="display:none;"></video>
        <video id="screenRaw" autoplay muted playsinline style="display:none;"></video>
      </div>
    </div>

    <div id="recordedCard" class="video-card fade-in" style="display:none;">
      <label class="t-Form-label">Recorded Clip</label>
      <div class="video-player">
        <video id="playback" controls></video>
      </div>
    </div>

    <div class="t-ButtonRegion t-ButtonRegion--noBorder">
      <button id="btnStart" type="button" class="t-Button t-Button--hot">Start Recording</button>
      <button id="btnStop" type="button" class="t-Button t-Button--stop" disabled>Stop</button>
      <span id="timer">00:00</span>
    </div>

    <div id="postButtons" class="t-ButtonRegion t-ButtonRegion--noBorder fade-in" style="display:none;">
      <button id="btnDownload" type="button" class="t-Button t-Button--ghost">Download</button>
      <button id="btnSave" type="button" class="t-Button t-Button--primary">Save to Database</button>
      <button id="btnRetry" type="button" class="t-Button t-Button--danger">Retry</button>
    </div>

    <div id="progressWrap" style="display:none;">
      <div id="progressBar"></div>
    </div>

    <div id="status"></div>
  </div>
</div>

Nothing complicated here. A canvas to draw the merged video, two hidden video tags that just act as sources for the canvas to pull frames from, and the usual buttons.

The Styling

I did not want it to look like a bare default APEX region, so I added a proper panel look with a pulsing REC badge and a glowing border while recording is active. This part is purely visual, does not affect the recording logic at all, but it makes the page feel like an actual recording tool instead of a demo.

CSS
#screenWebcamRec {
  --accent: #185FA5;
  --accent2: #4FB8E8;
  --success: #0F6E56;
  --danger: #E24B4A;
  font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
}

.rec-panel {
  background: linear-gradient(135deg, #f4f8fc 0%, #eef2f9 100%);
  border-radius: 20px;
  padding: 28px 32px;
  box-shadow: 0 8px 30px rgba(24, 95, 165, 0.08), 0 1px 3px rgba(0,0,0,0.04);
  border: 1px solid rgba(24, 95, 165, 0.08);
  max-width: 900px;
}

.rec-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 18px;
}

.rec-badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: #fff0f0;
  color: #d21f1f;
  font-weight: 700;
  font-size: 12px;
  padding: 5px 12px;
  border-radius: 20px;
  border: 1px solid #ffd2d2;
}

.rec-dot {
  width: 8px;
  height: 8px;
  background: #ff3b3b;
  border-radius: 50%;
  display: inline-block;
  animation: dotPulse 1s ease-in-out infinite;
}

@keyframes dotPulse {
  0%, 100% { transform: scale(1); opacity: 1; }
  50% { transform: scale(1.4); opacity: .5; }
}

.video-player {
  position: relative;
  width: 100%;
  max-width: 720px;
  aspect-ratio: 16 / 9;
  background: #0a0e14;
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 10px 40px rgba(0,0,0,0.18);
}

.video-player.is-recording {
  animation: recordGlow 1.8s ease-in-out infinite;
}

@keyframes recordGlow {
  0%, 100% { box-shadow: 0 0 0 0 rgba(226,75,74,0), 0 10px 40px rgba(0,0,0,0.18); }
  50% { box-shadow: 0 0 0 4px rgba(226,75,74,.35), 0 10px 40px rgba(0,0,0,0.18); }
}

.t-Button {
  border: none;
  border-radius: 10px;
  padding: 10px 20px;
  font-weight: 600;
  cursor: pointer;
  transition: transform .15s ease, box-shadow .2s ease;
}

.t-Button:hover:not(:disabled) {
  transform: translateY(-2px);
  box-shadow: 0 6px 16px rgba(0,0,0,0.14);
}

.t-Button--hot {
  background: linear-gradient(135deg, var(--accent), var(--accent2));
  color: #fff;
}

.t-Button--primary {
  background: linear-gradient(135deg, var(--success), #14a37f);
  color: #fff;
}

#progressBar {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--accent), var(--accent2));
  transition: width .2s ease;
  border-radius: 8px;
}

The JavaScript That Does the Actual Recording

This is the part I rewrote maybe four or five times. The final version does five things in order. Grabs the screen stream, grabs the webcam stream, draws both onto the canvas every frame, turns the canvas into its own stream, and records that combined stream.

JavaScript
(function () {

  const canvas = document.getElementById('mixCanvas');
  const ctx = canvas.getContext('2d');
  const screenRaw = document.getElementById('screenRaw');
  const webcamRaw = document.getElementById('webcamRaw');
  const playback = document.getElementById('playback');
  const recordedCard = document.getElementById('recordedCard');
  const btnStart = document.getElementById('btnStart');
  const btnStop = document.getElementById('btnStop');
  const btnSave = document.getElementById('btnSave');
  const btnRetry = document.getElementById('btnRetry');
  const btnDownload = document.getElementById('btnDownload');
  const postButtons = document.getElementById('postButtons');
  const statusEl = document.getElementById('status');
  const timerEl = document.getElementById('timer');
  const progressWrap = document.getElementById('progressWrap');
  const progressBar = document.getElementById('progressBar');
  const recBadge = document.getElementById('recBadge');
  const videoPlayer = document.querySelector('#screenWebcamRec .video-player');

  let screenStream, webcamStream, canvasStream;
  let mediaRecorder, chunks = [];
  let drawLoopId, timerInterval, startTime;
  let lastRecording = { blob: null, url: null, mime: null, durationMs: 0, filename: null };

  function formatMs(ms) {
    const s = Math.floor(ms / 1000);
    return String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0');
  }

  function setStatus(msg, cls) {
    statusEl.className = cls || '';
    statusEl.textContent = msg;
  }

  function drawFrame() {
    ctx.drawImage(screenRaw, 0, 0, canvas.width, canvas.height);
    const pipW = 300, pipH = 220, x = canvas.width - pipW - 24, y = canvas.height - pipH - 24;
    ctx.save();
    ctx.beginPath();
    if (ctx.roundRect) ctx.roundRect(x, y, pipW, pipH, 12);
    else ctx.rect(x, y, pipW, pipH);
    ctx.clip();
    ctx.drawImage(webcamRaw, x, y, pipW, pipH);
    ctx.restore();
    ctx.strokeStyle = '#ffffff';
    ctx.lineWidth = 3;
    ctx.strokeRect(x, y, pipW, pipH);
    drawLoopId = requestAnimationFrame(drawFrame);
  }

  async function startRecording() {
    try {
      screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
      screenRaw.srcObject = screenStream;

      webcamStream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } },
        audio: true
      });
      webcamRaw.srcObject = webcamStream;

      drawFrame();

      canvasStream = canvas.captureStream(30);
      screenStream.getAudioTracks().concat(webcamStream.getAudioTracks()).forEach(function(t) {
        canvasStream.addTrack(t);
      });

      chunks = [];
      let options = { mimeType: 'video/webm;codecs=vp8,opus' };
      try { mediaRecorder = new MediaRecorder(canvasStream, options); }
      catch (e) { mediaRecorder = new MediaRecorder(canvasStream); }

      mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) chunks.push(e.data); };
      mediaRecorder.onstop = onStopHandler;
      mediaRecorder.start(500);

      startTime = Date.now();
      timerEl.textContent = '00:00';
      timerInterval = setInterval(function() {
        timerEl.textContent = formatMs(Date.now() - startTime);
      }, 200);

      btnStart.disabled = true;
      btnStop.disabled = false;
      setStatus('Recording screen and webcam...');
      postButtons.style.display = 'none';
      recordedCard.style.display = 'none';
      progressWrap.style.display = 'none';
      recBadge.style.display = 'inline-flex';
      videoPlayer.classList.add('is-recording');

      screenStream.getVideoTracks()[0].onended = stopRecording;

    } catch (err) {
      setStatus('Permission denied or cancelled: ' + err.message, 'error');
    }
  }

  function onStopHandler() {
    const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'video/webm' });
    const url = URL.createObjectURL(blob);
    lastRecording = {
      blob: blob,
      url: url,
      mime: blob.type,
      durationMs: Date.now() - startTime,
      filename: 'screen_webcam_' + Date.now() + '.webm'
    };

    clearInterval(timerInterval);
    playback.src = url;
    recordedCard.style.display = '';
    postButtons.style.display = '';
    setStatus('Recording ready. Download or Save.');
  }

  function stopRecording() {
    cancelAnimationFrame(drawLoopId);
    if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
    screenStream && screenStream.getTracks().forEach(function(t) { t.stop(); });
    webcamStream && webcamStream.getTracks().forEach(function(t) { t.stop(); });
    btnStart.disabled = false;
    btnStop.disabled = true;
    recBadge.style.display = 'none';
    videoPlayer.classList.remove('is-recording');
  }

  function downloadRecording() {
    if (!lastRecording.blob) { apex.message.alert('Nothing recorded yet.'); return; }
    const a = document.createElement('a');
    a.href = lastRecording.url;
    a.download = lastRecording.filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
  }

  function blobToBase64(blob) {
    return new Promise(function(resolve, reject) {
      const reader = new FileReader();
      reader.onloadend = function() { resolve(reader.result); };
      reader.onerror = reject;
      reader.readAsDataURL(blob);
    });
  }

  async function saveRecording() {
    if (!lastRecording.blob) { apex.message.alert('Nothing to save yet.'); return; }

    btnSave.disabled = true;
    progressWrap.style.display = '';
    progressBar.style.width = '0%';
    setStatus('Preparing upload...');

    const base64 = await blobToBase64(lastRecording.blob);
    const payload = base64.split(',')[1];
    const CHUNK_SIZE = 28000;
    const totalChunks = Math.ceil(payload.length / CHUNK_SIZE);

    try {
      await apex.server.process('SAVE_VIDEO_INIT', {
        x01: lastRecording.filename,
        x02: lastRecording.mime,
        x03: String(lastRecording.durationMs)
      }, { dataType: 'json' });

      for (let i = 0; i < totalChunks; i++) {
        const chunk = payload.substr(i * CHUNK_SIZE, CHUNK_SIZE);
        await apex.server.process('SAVE_VIDEO_CHUNK', { x01: chunk }, { dataType: 'json' });
        const pct = Math.round(((i + 1) / totalChunks) * 100);
        progressBar.style.width = pct + '%';
        setStatus('Uploading... ' + pct + '%');
      }

      const result = await apex.server.process('SAVE_VIDEO_FINALIZE', {}, { dataType: 'json' });

      if (result.status === 'ok') {
        setStatus('"' + lastRecording.filename + '" saved successfully to database.', 'success');
        postButtons.style.display = 'none';
        recordedCard.style.display = 'none';
        progressWrap.style.display = 'none';
      } else {
        setStatus('Save failed: ' + result.message, 'error');
      }

    } catch (err) {
      setStatus('Upload failed: ' + (err.message || err), 'error');
    } finally {
      btnSave.disabled = false;
    }
  }

  function retry() {
    lastRecording.url && URL.revokeObjectURL(lastRecording.url);
    playback.removeAttribute('src');
    recordedCard.style.display = 'none';
    postButtons.style.display = 'none';
    progressWrap.style.display = 'none';
    setStatus('');
    timerEl.textContent = '00:00';
  }

  btnStart.addEventListener('click', startRecording);
  btnStop.addEventListener('click', stopRecording);
  btnSave.addEventListener('click', saveRecording);
  btnDownload.addEventListener('click', downloadRecording);
  btnRetry.addEventListener('click', retry);

})();
One issue I faced right here was the audio tracks. On my first version I forgot to add the webcam audio track into the canvas stream and only added the screen audio. The recording worked fine but my voice from the mic was completely missing, only system audio came through. Adding both tracks with a simple loop fixed it.

Getting It Into the Database Was Its Own Fight

Recording and previewing the video worked fine on the first proper attempt. Saving it into an Oracle table was where I lost the most time. If this part sounds familiar, it is because the upload plumbing here builds directly on the chunking approach I first worked out in Part 2.

Attempt one, straight base64 to an Ajax Callback

My first save process just took the whole base64 string in one go through apex_application.g_x01 and inserted it. It said saved successfully every single time. I opened the table. Nothing was there.

Turns out Ajax Callback processes in APEX do not auto commit like a page submit process does. My insert was running fine but never getting committed, so it just vanished. Adding a plain COMMIT after the insert fixed that part immediately.

But even after adding the commit, longer recordings still failed silently. That is when I remembered that apex_application.g_x01 through g_x10 are VARCHAR2 parameters, capped around 32000 characters. A few seconds of base64 video easily blows past that limit, so the payload was getting cut off before it even reached my PLSQL block.

Attempt two, chunked upload using a collection

The fix was to stop sending the whole video in one call and instead send it in small pieces, well under the 32k limit, appending each piece to a CLOB on the server side, then converting the final CLOB to a BLOB once all chunks arrive. I used an APEX Collection as a temporary holding spot for this.

First hiccup here, I tried a plain SQL UPDATE directly on the apex_collections view to append each chunk.

Got ORA-06550 with insufficient privileges. Turns out apex_collections is a protected view, you are not allowed to run direct DML against it. Everything has to go through the apex_collection package procedures instead.

Second hiccup, I switched to apex_collection.update_member_attribute thinking that would let me update the CLOB column.

Got PLS-00306, wrong number or types of arguments. That procedure only updates the c001 to c050 varchar columns or the number columns, it does not accept a CLOB parameter at all. The correct procedure for that is update_member, which replaces the entire row, so I had to also re-pass the filename, mime type and duration each time or they would get wiped out.

Once I switched to update_member and passed all the existing column values back along with the appended CLOB, the chunked upload finally worked end to end, and rows started showing up properly in my table with the video BLOB fully intact.

The Final Working SQL

SQL, Table and Three Processes
CREATE TABLE screen_webcam_capture (
  id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  filename VARCHAR2(255),
  mime_type VARCHAR2(100),
  blob_content BLOB,
  duration_ms NUMBER,
  created_by VARCHAR2(100),
  created_on DATE DEFAULT SYSDATE
);

BEGIN
  IF apex_collection.collection_exists('VIDEO_UPLOAD') THEN
    apex_collection.delete_collection('VIDEO_UPLOAD');
  END IF;
  apex_collection.create_collection('VIDEO_UPLOAD');

  apex_collection.add_member(
    p_collection_name => 'VIDEO_UPLOAD',
    p_c001 => apex_application.g_x01,
    p_c002 => apex_application.g_x02,
    p_c003 => apex_application.g_x03,
    p_clob001 => EMPTY_CLOB()
  );

  apex_json.open_object;
  apex_json.write('status', 'ok');
  apex_json.close_object;
EXCEPTION
  WHEN OTHERS THEN
    apex_json.open_object;
    apex_json.write('status', 'error');
    apex_json.write('message', SQLERRM);
    apex_json.close_object;
END;

DECLARE
  l_clob CLOB;
  l_seq NUMBER;
  l_c001 VARCHAR2(4000);
  l_c002 VARCHAR2(4000);
  l_c003 VARCHAR2(4000);
BEGIN
  SELECT seq_id, clob001, c001, c002, c003
  INTO l_seq, l_clob, l_c001, l_c002, l_c003
  FROM apex_collections
  WHERE collection_name = 'VIDEO_UPLOAD'
    AND seq_id = (SELECT MIN(seq_id) FROM apex_collections WHERE collection_name = 'VIDEO_UPLOAD');

  IF l_clob IS NULL THEN
    l_clob := apex_application.g_x01;
  ELSE
    l_clob := l_clob || apex_application.g_x01;
  END IF;

  apex_collection.update_member(
    p_collection_name => 'VIDEO_UPLOAD',
    p_seq => l_seq,
    p_c001 => l_c001,
    p_c002 => l_c002,
    p_c003 => l_c003,
    p_clob001 => l_clob
  );

  apex_json.open_object;
  apex_json.write('status', 'ok');
  apex_json.close_object;
EXCEPTION
  WHEN OTHERS THEN
    apex_json.open_object;
    apex_json.write('status', 'error');
    apex_json.write('message', SQLERRM);
    apex_json.close_object;
END;

DECLARE
  l_blob BLOB;
  l_filename VARCHAR2(255);
  l_mime VARCHAR2(100);
  l_duration NUMBER;
  l_clob CLOB;
BEGIN
  SELECT c001, c002, c003, clob001
  INTO l_filename, l_mime, l_duration, l_clob
  FROM apex_collections
  WHERE collection_name = 'VIDEO_UPLOAD'
    AND seq_id = (SELECT MIN(seq_id) FROM apex_collections WHERE collection_name = 'VIDEO_UPLOAD');

  l_blob := apex_web_service.clobbase642blob(l_clob);

  INSERT INTO screen_webcam_capture (filename, mime_type, blob_content, duration_ms, created_by)
  VALUES (l_filename, l_mime, l_blob, l_duration, v('APP_USER'));

  COMMIT;

  apex_collection.delete_collection('VIDEO_UPLOAD');

  apex_json.open_object;
  apex_json.write('status', 'ok');
  apex_json.close_object;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    apex_json.open_object;
    apex_json.write('status', 'error');
    apex_json.write('message', SQLERRM);
    apex_json.close_object;
END;

What I Would Tell Someone Starting This Fresh

  • Do not try to record two separate streams and merge them later. Draw both onto one canvas and record the canvas.
  • Always add COMMIT in Ajax Callback processes, it does not happen automatically like a normal page process.
  • Never send big base64 payloads through g_x01 style parameters, chunk it once you cross a few thousand characters.
  • Do not run direct DML on apex_collections, always go through the apex_collection package.
  • update_member_attribute is for small text or number columns only, use update_member when a CLOB or BLOB is involved.

That is basically the entire journey from idea to a fully working recorder. It took longer than I expected mainly because of the database side, the recording part itself came together faster once I understood the canvas trick.

Catch up on the rest of the series

This post is Part 3. If you missed the earlier builds, start here.

JS
Written by Jefith Shalin
Sharing what actually happened while building things in Oracle APEX, mistakes included.

Comments

Popular posts from this blog

Face Detection in Oracle Apex

Face Detection Blog - Embedded JS Jefith Shalin Oracle APEX Developer · May 2025 Live Demo GitHub Oracle APEX · AI Integration A Developer's Real Story Building a Face Detection Attendance System in Oracle APEX A real story of trial, error, and finally making face-api.js actually work inside an APEX app. No fluff — just what happened and what finally worked. Try Live Demo View on GitHub 0.22.2 face-api.js Version 2 APEX Pages Built 4 AJAX Processes face-api.js Oracle APEX JavaScript AI / ML Face Recognition CDN Attendance System PL/SQL The Beginning So here is how it all started I was tasked with building an attendance system for our off...

Screen Recorder in Oracle APEX (Single Page)

Oracle APEX Tutorial Screen Recorder in Oracle APEX : Single Page Build a fully functional browser-based screen recorder inside Oracle APEX using just one Static Content region and native JavaScript. No plugins, no external libraries, no server uploads required. ✍️ Why I Built This I was working on a client project where the support team needed to record screen issues and share them directly from the APEX application, without switching to any external tool. Installing third-party software was not an option on their machines, and every screen recorder extension required IT approval. That is when I thought: the browser already has everything we need. Why not build it right inside APEX? That idea turned into this. &#127916; Start / Stop Recording &#128065; Instant Preview ⬇️ One-click Download &#128266; Audio + Video ...

Sticky Notes Widget Inside Oracle APEX

Oracle APEX Project Building a Sticky Notes Widget in Oracle APEX How I built a fully draggable, color-coded, per-user sticky notes board using jQuery UI, APEX Ajax callbacks, and a bit of patience. Live Demo GitHub Repo Oracle APEX jQuery UI PL/SQL Ajax Callbacks JavaScript CSS Introduction Why I Built This I have been building internal tools on Oracle APEX for a while now, and one thing I always felt was missing was a place where users could quickly jot down thoughts without leaving the page. Think of it like a personal scratchpad that lives right inside the app. I had seen sticky note UIs in some Google products and I thought, how hard can this be in APEX? It turned out to be more interesting than I expected. There were a few wrong tu...