<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tesla Dashcam SEI Explorer</title>
<style>
:root {
color-scheme: light dark;
--bg:
#ffffff;
--bg-panel:
#f5f5f5;
--text:
#1a1a1a;
--text-muted: #666666;
--accent:
#0080ff;
--accent-hover:
#0099ff;
--border: #888888;
--drop-hover-bg:
#e8f4ff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg:
#1a1a1a;
--bg-panel: #252525;
--text:
#e8e8e8;
--text-muted: #999999;
--accent: rgb(255, 0, 51);
--accent-hover: rgb(200, 0, 40);
--border: #666666;
--drop-hover-bg: #291818;
}
}
* {
box-sizing: border-box;
margin: 0;
}
html,
body {
height: 100%;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.4;
}
a {
color: var(--accent);
}
/* ===== APP SHELL ===== */
.app {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
gap: 0.75rem;
}
/* Header - fixed height */
.header {
flex-shrink: 0;
}
.header .nav {
font-size: 0.85rem;
margin-bottom: 0.25rem;
}
.header h1 {
font-size: 1.4rem;
margin-bottom: 0.125rem;
}
.header .subtitle {
color: var(--text-muted);
font-size: 0.85rem;
}
/* Main content - fills remaining space */
.main {
flex: 1;
min-height: 0;
/* Critical for flex children to shrink */
display: flex;
gap: 1rem;
}
/* ===== COLUMN LAYOUT (default: wide screens) ===== */
/* Left: Video Controls */
.video-section {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Right: Metadata Export */
.meta-section {
width: 285px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* ===== VIDEO AREA ===== */
.video-wrap {
flex: 1;
min-height: 0;
background: #000;
border-radius: 8px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.video-wrap canvas {
max-width: 100%;
max-height: 100%;
}
/* Drop overlay */
.drop-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-panel);
border: 2px dashed var(--border);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.drop-overlay.hidden {
display: none;
}
.drop-overlay:hover,
.drop-overlay.dragover {
background: var(--drop-hover-bg);
border-color: var(--accent);
}
.drop-overlay p {
color: var(--text-muted);
font-size: 0.9rem;
}
.drop-overlay input {
display: none;
}
/* ===== SCRUBBER ===== */
.scrubber {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-panel);
border-radius: 6px;
height: 44px;
}
.scrubber button {
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--accent);
color:
#fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, opacity 0.15s;
}
.scrubber button:hover:not(:disabled) {
background: var(--accent-hover);
}
.scrubber button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.scrubber button svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.scrubber input[type="range"] {
flex: 1;
height: 4px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background: transparent;
padding: 12px 0;
margin: -12px 0;
}
.scrubber input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
background: var(--border);
border-radius: 2px;
}
.scrubber input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: var(--accent);
border-radius: 50%;
margin-top: -5px;
cursor: pointer;
}
.scrubber input[type="range"]::-moz-range-track {
height: 4px;
background: var(--border);
border-radius: 2px;
}
.scrubber input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--accent);
border: none;
border-radius: 50%;
cursor: pointer;
}
.scrubber input[type="range"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ===== METADATA PANEL ===== */
.meta-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-panel);
border-radius: 6px;
overflow: hidden;
padding-bottom: 3px;
}
.meta-header {
flex-shrink: 0;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.meta-header .filename {
font-weight: 600;
font-size: 0.75rem;
word-break: break-all;
}
.meta-header .framenum {
font-size: 0.7rem;
color: var(--text-muted);
}
.meta-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.meta-list .item {
display: flex;
flex-direction: column;
}
.meta-list .label {
font-size: 0.7rem;
color: var(--text-muted);
}
.meta-list .value {
font-weight: 600;
color: var(--accent);
}
/* Export button */
.export-btn {
flex-shrink: 0;
height: 44px;
padding: 0 0.75rem;
border: none;
border-radius: 6px;
background: var(--accent);
color:
#fff;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.export-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.export-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ===== ROW LAYOUT (short/narrow screens) ===== */
@media (max-height: 600px),
(max-width: 600px) {
.main {
flex-direction: column;
}
.video-section {
flex: none;
}
.video-wrap {
flex: none;
min-height: 200px;
max-height: 40vh;
}
.meta-section {
width: auto;
flex: 1;
min-height: 120px;
}
.meta-panel {
flex: 1;
min-height: 300px;
}
.meta-list {
flex-direction: row;
flex-wrap: wrap;
/* gap: 0.5rem; */
}
.meta-list .item {
min-width: 90px;
flex: 1;
}
}
</style>
</head>
<body>
<div class="app">
<header class="header">
<p class="nav"><a href="index.html">← Back to Dashcam Tools</a></p>
<h1>Tesla Dashcam SEI Explorer</h1>
<p class="subtitle">View dashcam footage with embedded SEI metadata. All processing happens locally in your
browser—no video/SEI data is uploaded. (<a
href="
github.com/teslamotors/dashc…">view source</a>)</p>
</header>
<main class="main">
<section class="video-section">
<div class="video-wrap">
<div class="drop-overlay" id="dropOverlay">
<input type="file" id="fileInput" accept="video/mp4" multiple>
<p>Drop MP4 here or click to select</p>
</div>
<canvas id="canvas"></canvas>
</div>
<div class="scrubber">
<button id="playBtn" title="Play/Pause" disabled>
<svg viewBox="0 0 24 24">
<polygon points="6,4 20,12 6,20" />
</svg>
</button>
<input type="range" id="slider" min="0" max="0" value="0" disabled>
</div>
</section>
<aside class="meta-section">
<div class="meta-panel">
<div class="meta-header">
<div class="filename" id="fileName">No file loaded</div>
<div class="framenum" id="frameNum">Frame 0/0</div>
</div>
<div class="meta-list" id="metaList"></div>
</div>
<button class="export-btn" id="exportBtn" disabled>Export CSV</button>
</aside>
</main>
</div>
<script src="vendor/protobuf.min.js"></script>
<script src="vendor/jszip.min.js"></script>
<script src="dashcam-mp4.js"></script>
<script>
// State
let seiType = null, seiFields = null, seiFieldsCsv = null;
let mp4 = null, frames = null, firstKeyframe = 0;
let currentFileName = null;
let decoder = null, decoding = false, pendingFrame = null;
let playing = false, playTimer = null;
// DOM
const $ = id => document.getElementById(id);
const dropOverlay = $('dropOverlay'), fileInput = $('fileInput');
const canvas = $('canvas'), ctx = canvas.getContext('2d');
const slider = $('slider'), playBtn = $('playBtn'), exportBtn = $('exportBtn');
const frameNum = $('frameNum'), fileName = $('fileName'), metaList = $('metaList');
// Events
dropOverlay.onclick = () =>
fileInput.click();
fileInput.onchange = e => { handleFiles(
e.target.files);
e.target.value = ''; };
document.ondragover = e => { e.preventDefault(); dropOverlay.classList.add('dragover'); };
document.ondragleave = e => { if (!e.relatedTarget) dropOverlay.classList.remove('dragover'); };
document.ondrop = async e => {
e.preventDefault();
dropOverlay.classList.remove('dragover');
const items = e.dataTransfer?.items;
if (items) {
const { files, directoryName } = await DashcamHelpers.getFilesFromDataTransfer(items);
handleFiles(files, directoryName);
} else {
handleFiles(e.dataTransfer?.files ?? []);
}
};
slider.oninput = () => { pause(); showFrame( slider.value); };
playBtn.onclick = () => playing ? pause() : play();
document.onkeydown = e => {
if (!frames) return;
if (e.key === ' ') {
e.preventDefault(); playing ? pause() : play();
} else if (e.key === 'ArrowLeft' && slider.value > 0) {
e.preventDefault(); pause(); slider.value = slider.value - 1; showFrame( slider.value);
} else if (e.key === 'ArrowRight' && slider.value < frames.length - 1) {
e.preventDefault(); pause(); slider.value = slider.value 1; showFrame( slider.value);
}
};
exportBtn.onclick = exportCsv;
// Initialize protobuf
DashcamHelpers.initProtobuf().then(({ SeiMetadata, enumFields }) => {
seiType = SeiMetadata;
seiFields = DashcamHelpers.deriveFieldInfo(SeiMetadata, enumFields, { useLabels: true });
seiFieldsCsv = DashcamHelpers.deriveFieldInfo(SeiMetadata, enumFields, { useSnakeCase: true });
}).catch(err => console.error('Protobuf init failed:', err));
async function handleFiles(fileList, directoryName = null) {
if (!seiType) await DashcamHelpers.initProtobuf().then(({ SeiMetadata, enumFields }) => {
seiType = SeiMetadata;
seiFields = DashcamHelpers.deriveFieldInfo(SeiMetadata, enumFields, { useLabels: true });
seiFieldsCsv = DashcamHelpers.deriveFieldInfo(SeiMetadata, enumFields, { useSnakeCase: true });
});
const files = (Array.isArray(fileList) ? fileList : Array.from(fileList))
.filter(f =>
f.name.toLowerCase().endsWith('.mp4'));
if (!files.length) { alert('Please choose at least one MP4 file.'); return; }
// Single file: load and display video
if (files.length === 1) { loadFile(files[0]); return; }
// Multiple files: extract metadata and create zip
const zip = new JSZip();
let exported = 0;
for (const file of files) {
try {
const mp4 = new DashcamMP4(await file.arrayBuffer());
const messages = mp4.extractSeiMessages(seiType);
if (messages.length) {
zip.file(
file.name.replace(/\.mp4$/i, '_sei.csv'), DashcamHelpers.buildCsv(messages, seiFieldsCsv));
exported ;
}
} catch { }
}
if (!exported) { alert('No files produced SEI metadata.'); return; }
DashcamHelpers.downloadBlob(await zip.generateAsync({ type: 'blob' }), directoryName ? `${directoryName}_sei.zip` : 'dashcam_sei_metadata.zip');
alert(`Exported ${exported} CSV${exported > 1 ? 's' : ''} as ZIP. To view a clip, select a single file.`);
}
async function loadFile(file) {
if (!file) return;
if (!
file.name.toLowerCase().endsWith('.mp4')) {
alert('Please select an MP4 file');
return;
}
if (!seiType) { alert('Protobuf not initialized'); return; }
pause();
if (decoder) { try { decoder.close(); } catch { } decoder = null; }
metaList.innerHTML = '';
ctx.clearRect(0, 0, canvas.width, canvas.height);
try {
mp4 = new DashcamMP4(await file.arrayBuffer());
frames = mp4.parseFrames(seiType);
firstKeyframe = frames.findIndex(f => f.keyframe);
if (firstKeyframe === -1) throw new Error('No keyframes found');
const config = mp4.getConfig();
canvas.width = config.width;
canvas.height = config.height;
slider.max = frames.length - 1;
slider.value = firstKeyframe;
dropOverlay.classList.add('hidden');
currentFileName =
file.name;
fileName.textContent =
file.name;
exportBtn.disabled = false;
playBtn.disabled = false;
slider.disabled = false;
showFrame(firstKeyframe);
} catch (err) {
mp4 = null;
frames = null;
firstKeyframe = 0;
currentFileName = null;
slider.max = 0;
slider.value = 0;
frameNum.textContent = 'Frame 0/0';
fileName.textContent = 'No file loaded';
exportBtn.disabled = true;
playBtn.disabled = true;
slider.disabled = true;
dropOverlay.classList.remove('hidden');
alert(err.message);
}
}
function play() {
if (!frames || playing) return;
playing = true;
playBtn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="5" y="4" width="4" height="16"/><rect x="15" y="4" width="4" height="16"/></svg>';
playNext();
}
function pause() {
playing = false;
playBtn.innerHTML = '<svg viewBox="0 0 24 24"><polygon points="6,4 20,12 6,20"/></svg>';
if (playTimer) { clearTimeout(playTimer); playTimer = null; }
}
function playNext() {
if (!playing) return;
let next = slider.value 1;
if (next >= frames.length) next = firstKeyframe;
slider.value = next;
showFrame(next);
playTimer = setTimeout(playNext, mp4.getConfig().durations[next] || 33);
}
function showFrame(index) {
frameNum.textContent = `Frame ${index 1}/${frames.length}`;
renderSei(frames[index].sei);
if (decoding) { pendingFrame = index; return; }
decodeFrame(index);
}
async function decodeFrame(index) {
/* Naive decoding strategy: start from the preceding keyframe and decode up to the target frame.
This is not optimal in all cases since it re-decodes frames that have already been decoded. */
decoding = true;
try {
let keyIdx = index;
while (keyIdx >= 0 && !frames[keyIdx].keyframe) keyIdx--;
if (keyIdx < 0) { showError('No preceding keyframe'); return; }
if (decoder) try { decoder.close(); } catch { }
let count = 0;
const target = index - keyIdx 1;
const decodePromise = new Promise((resolve, reject) => {
decoder = new VideoDecoder({
output: frame => {
if ( count === target) ctx.drawImage(frame, 0, 0);
frame.close();
if (count >= target) resolve();
},
error: reject
});
const config = mp4.getConfig();
decoder.configure({ codec: config.codec, width: config.width, height: config.height });
for (let i = keyIdx; i <= index; i ) decoder.decode(createChunk(frames[i]));
decoder.flush().catch(reject);
});
await decodePromise;
} catch (err) {
if (!err.message?.includes('Aborted')) showError('Decode failed');
} finally {
decoding = false;
if (pendingFrame !== null) { const n = pendingFrame; pendingFrame = null; decodeFrame(n); }
}
}
function createChunk(frame) {
const sc = new Uint8Array([0, 0, 0, 1]);
const config = mp4.getConfig();
const data = frame.keyframe
? DashcamMP4.concat(sc, frame.sps || config.sps, sc, frame.pps || config.pps, sc,
frame.data)
: DashcamMP4.concat(sc,
frame.data);
return new EncodedVideoChunk({ type: frame.keyframe ? 'key' : 'delta', timestamp: frame.index * 33333, data });
}
function renderSei(sei) {
metaList.innerHTML = '';
for (const { propName, label, enumMap } of seiFields) {
const value = sei?.[propName];
const item = document.createElement('div');
item.className = 'item';
const displayValue = value != null ? DashcamHelpers.formatValue(value, enumMap) : '—';
item.innerHTML = `<span class="label">${label}</span><span class="value">${displayValue}</span>`;
metaList.appendChild(item);
}
}
async function exportCsv() {
if (!frames || !seiFieldsCsv) return;
const messages =
frames.map(f => f.sei).filter(Boolean);
if (!messages.length) { alert('No SEI metadata to export.'); return; }
const filename = (currentFileName || 'dashcam').replace(/\.mp4$/i, '') '_sei.csv';
DashcamHelpers.downloadBlob(new Blob([DashcamHelpers.buildCsv(messages, seiFieldsCsv)], { type: 'text/csv' }), filename);
}
function showError(msg) {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#888';
ctx.font = 'bold 32px system-ui';
ctx.textAlign = 'center';
ctx.fillText(msg, canvas.width / 2, canvas.height / 2);
}
</script>
</body>
</html>