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.
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.
<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.
#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.
(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);
})();
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.
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.
Second hiccup, I switched to apex_collection.update_member_attribute thinking that would let me update the CLOB column.
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
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.
Sharing what actually happened while building things in Oracle APEX, mistakes included.
Comments
Post a Comment