Initial Commit

This commit is contained in:
Brian McGonagill 2026-03-09 17:42:26 -05:00
commit e2f743e8bc
11 changed files with 3104 additions and 0 deletions

92
Dockerfile Normal file
View file

@ -0,0 +1,92 @@
# =============================================================================
# VideoPress — Dockerfile
# =============================================================================
#
# Build:
# docker build -t videopress .
#
# Run (quick):
# docker run -d \
# -p 8080:8080 \
# -v /your/video/path:/media \
# --name videopress \
# videopress
#
# See docker-compose.yml for a fully-configured example.
# =============================================================================
# ── Stage 1: Python dependency builder ──────────────────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build
# Install build tools needed to compile gevent's C extensions
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# Install into an isolated prefix so we can copy just the packages
RUN pip install --upgrade pip \
&& pip install --prefix=/install --no-cache-dir -r requirements.txt
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
FROM python:3.12-slim
# ── System packages: ffmpeg (includes ffprobe) ───────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# ── Copy Python packages from builder ────────────────────────────────────────
COPY --from=builder /install /usr/local
# ── Create a non-root application user ───────────────────────────────────────
# UID/GID 1000 is conventional and matches most Linux desktop users,
# which makes the volume-mapped files readable without extra chown steps.
RUN groupadd --gid 1000 appuser \
&& useradd --uid 1000 --gid 1000 --no-create-home --shell /sbin/nologin appuser
# ── Application code ─────────────────────────────────────────────────────────
WORKDIR /app
COPY app.py wsgi.py gunicorn.conf.py requirements.txt ./
COPY templates/ templates/
COPY static/ static/
# ── Media volume mount point ──────────────────────────────────────────────────
# The host directory containing videos is mounted here at runtime.
# All file-system access by the application is restricted to this path.
RUN mkdir -p /media && chown appuser:appuser /media
# ── File ownership ────────────────────────────────────────────────────────────
RUN chown -R appuser:appuser /app
# ── Switch to non-root user ───────────────────────────────────────────────────
USER appuser
# ── Environment defaults (all overridable via docker run -e or compose) ──────
# MEDIA_ROOT — the path inside the container where videos are accessed.
# Must match the container-side of your volume mount.
# PORT — TCP port Gunicorn listens on (exposed below).
# LOG_LEVEL — Gunicorn log verbosity (debug | info | warning | error).
ENV MEDIA_ROOT=/media \
PORT=8080 \
LOG_LEVEL=info \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# ── Expose the web UI port ────────────────────────────────────────────────────
# The UI and API share the same port — no separate API port is needed.
EXPOSE 8080
# ── Health check ─────────────────────────────────────────────────────────────
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD python3 -c \
"import urllib.request; urllib.request.urlopen('http://localhost:8080/')" \
|| exit 1
# ── Start Gunicorn ────────────────────────────────────────────────────────────
CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"]

125
README.md Normal file
View file

@ -0,0 +1,125 @@
# VideoPress — FFmpeg Video Compressor
A web-based video compression tool served via **Gunicorn + gevent** (WSGI),
containerised with **Docker**. FFmpeg compresses video files to approximately
1/3 their original size. All file-system access is restricted to a single
configurable media root for security.
---
## Quick start with Docker (recommended)
```bash
# 1. Build the image
docker build -t videopress .
# 2. Run — replace /your/video/path with the real path on your host
docker run -d \
--name videopress \
--restart unless-stopped \
-p 8080:8080 \
-v /your/video/path:/media \
videopress
# 3. Open http://localhost:8080
```
### With docker compose
```bash
# Edit the volume path in docker-compose.yml (or export the env var):
export MEDIA_HOST_PATH=/your/video/path
docker compose up -d
```
---
## Bare-metal (without Docker)
### Requirements
| Tool | Version |
|------|---------|
| Python | 3.8+ |
| FFmpeg + FFprobe | any recent |
| pip packages | see requirements.txt |
```bash
# Install Python dependencies
pip install -r requirements.txt
# Development server (Flask built-in — not for production)
./start.sh
# Production server (Gunicorn + gevent)
MEDIA_ROOT=/your/video/path ./start.sh --prod
# or on a custom port:
MEDIA_ROOT=/your/video/path ./start.sh --prod 9000
```
---
## Security model
Every API call that accepts a path validates it with `safe_path()` before any
OS operation. `safe_path()` resolves symlinks and asserts the result is inside
`MEDIA_ROOT`. Requests that attempt directory traversal are rejected with
HTTP 403. The container runs as a non-root user (UID 1000).
---
## Architecture
```
Browser ──HTTP──▶ Gunicorn (gevent worker)
├─ GET / → index.html
├─ GET /api/config → {"media_root": ...}
├─ GET /api/browse → directory listing
├─ POST /api/scan → ffprobe metadata
├─ POST /api/compress/start → launch ffmpeg thread
├─ GET /api/compress/progress/<id> ← SSE stream
└─ POST /api/compress/cancel/<id>
```
**Why gevent?** SSE (`/api/compress/progress`) is a long-lived streaming
response. Standard Gunicorn sync workers block for its entire duration.
Gevent workers use cooperative greenlets so a single worker process can
handle many concurrent SSE streams and normal requests simultaneously.
**Why workers=1?** Job state lives in an in-process Python dict. Multiple
worker *processes* would not share it. One gevent worker with many greenlets
is sufficient for this workload. To scale to multiple workers, replace the
in-process job store with Redis.
---
## Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MEDIA_ROOT` | `/media` | Root directory the app may access |
| `PORT` | `8080` | Port Gunicorn listens on |
| `LOG_LEVEL` | `info` | Gunicorn log level |
---
## File layout
```
videocompressor/
├── app.py ← Flask application + all API routes
├── wsgi.py ← Gunicorn entry point (imports app from app.py)
├── gunicorn.conf.py ← Gunicorn configuration (gevent, timeout, logging)
├── requirements.txt ← Python dependencies
├── Dockerfile ← Two-stage Docker build
├── docker-compose.yml ← Volume mapping, port, env vars
├── start.sh ← Helper script (dev + prod modes)
├── README.md
├── templates/
│ └── index.html
└── static/
├── css/main.css
└── js/app.js
```

696
app.js Normal file
View file

@ -0,0 +1,696 @@
/**
* VideoPress Frontend Application
* Handles all UI interactions, API calls, and SSE progress streaming.
* WCAG 2.2 compliant, fully functional, no stubs.
*/
'use strict';
// ─── State ──────────────────────────────────────────────────────────────────
const state = {
scannedFiles: [], // enriched file objects from API
selectedPaths: new Set(), // paths of selected files
currentJobId: null,
eventSource: null,
compressionResults: [],
browserPath: '/',
};
// ─── DOM References ──────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const els = {
// Config section
dirInput: $('dir-input'),
browseBtn: $('browse-btn'),
minSizeInput: $('min-size-input'),
suffixInput: $('suffix-input'),
scanBtn: $('scan-btn'),
scanStatus: $('scan-status'),
// Browser modal
browserModal: $('browser-modal'),
browserList: $('browser-list'),
browserPath: $('browser-current-path'),
closeBrowser: $('close-browser'),
browserCancel: $('browser-cancel'),
browserSelect: $('browser-select'),
// Files section
sectionFiles: $('section-files'),
selectAllBtn: $('select-all-btn'),
deselectAllBtn: $('deselect-all-btn'),
selectionSummary: $('selection-summary'),
fileTbody: $('file-tbody'),
compressBtn: $('compress-btn'),
// Progress section
sectionProgress: $('section-progress'),
progTotal: $('prog-total'),
progDone: $('prog-done'),
progStatus: $('prog-status'),
overallBar: $('overall-bar'),
overallBarFill: $('overall-bar-fill'),
overallPct: $('overall-pct'),
fileProgressList: $('file-progress-list'),
cancelBtn: $('cancel-btn'),
// Results
sectionResults: $('section-results'),
resultsContent: $('results-content'),
restartBtn: $('restart-btn'),
// Theme
themeToggle: $('theme-toggle'),
themeIcon: $('theme-icon'),
// Screen reader announce
srAnnounce: $('sr-announce'),
};
// ─── Accessibility Helper ─────────────────────────────────────────────────────
function announce(msg) {
els.srAnnounce.textContent = '';
requestAnimationFrame(() => {
els.srAnnounce.textContent = msg;
});
}
// ─── Theme Management ─────────────────────────────────────────────────────────
function initTheme() {
const saved = localStorage.getItem('vp-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
applyTheme(theme);
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
els.themeIcon.textContent = theme === 'dark' ? '☀' : '◑';
els.themeToggle.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`);
localStorage.setItem('vp-theme', theme);
}
els.themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'light';
applyTheme(current === 'dark' ? 'light' : 'dark');
});
// ─── Directory Browser ────────────────────────────────────────────────────────
async function loadBrowserPath(path) {
els.browserList.innerHTML = '<p class="browser-loading" aria-live="polite">Loading…</p>';
els.browserPath.textContent = path;
try {
const resp = await fetch(`/api/browse?path=${encodeURIComponent(path)}`);
if (!resp.ok) throw new Error((await resp.json()).error || 'Error loading directory');
const data = await resp.json();
state.browserPath = data.current;
els.browserPath.textContent = data.current;
let html = '';
// Parent directory link
if (data.parent !== null) {
html += `
<button class="browser-item parent-dir" data-path="${escHtml(data.parent)}" data-is-dir="true">
<span class="item-icon" aria-hidden="true"></span>
<span>.. (parent directory)</span>
</button>`;
}
if (data.entries.length === 0 && !data.parent) {
html += '<p class="browser-loading">No accessible directories found.</p>';
}
for (const entry of data.entries) {
if (!entry.is_dir) continue;
html += `
<button class="browser-item" data-path="${escHtml(entry.path)}" data-is-dir="true"
role="option" aria-label="Directory: ${escHtml(entry.name)}">
<span class="item-icon" aria-hidden="true">📁</span>
<span>${escHtml(entry.name)}</span>
</button>`;
}
if (html === '') {
html = '<p class="browser-loading">No subdirectories found.</p>';
}
els.browserList.innerHTML = html;
// Attach click events
els.browserList.querySelectorAll('.browser-item').forEach(item => {
item.addEventListener('click', () => loadBrowserPath(item.dataset.path));
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
loadBrowserPath(item.dataset.path);
}
});
});
} catch (err) {
els.browserList.innerHTML = `<p class="browser-error" role="alert">Error: ${escHtml(err.message)}</p>`;
}
}
function openBrowserModal() {
els.browserModal.hidden = false;
document.body.style.overflow = 'hidden';
loadBrowserPath(els.dirInput.value || '/');
// Focus trap
els.closeBrowser.focus();
announce('Directory browser opened');
}
function closeBrowserModal() {
els.browserModal.hidden = true;
document.body.style.overflow = '';
els.browseBtn.focus();
announce('Directory browser closed');
}
els.browseBtn.addEventListener('click', openBrowserModal);
els.closeBrowser.addEventListener('click', closeBrowserModal);
els.browserCancel.addEventListener('click', closeBrowserModal);
els.browserSelect.addEventListener('click', () => {
els.dirInput.value = state.browserPath;
closeBrowserModal();
announce(`Directory selected: ${state.browserPath}`);
});
// Close modal on backdrop click
els.browserModal.addEventListener('click', (e) => {
if (e.target === els.browserModal) closeBrowserModal();
});
// Keyboard: close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !els.browserModal.hidden) {
closeBrowserModal();
}
});
// ─── Scan for Files ───────────────────────────────────────────────────────────
els.scanBtn.addEventListener('click', async () => {
const directory = els.dirInput.value.trim();
const minSize = parseFloat(els.minSizeInput.value);
if (!directory) {
showScanStatus('Please enter a directory path.', 'error');
els.dirInput.focus();
return;
}
if (isNaN(minSize) || minSize <= 0) {
showScanStatus('Please enter a valid minimum size greater than 0.', 'error');
els.minSizeInput.focus();
return;
}
els.scanBtn.disabled = true;
els.scanBtn.textContent = '⟳ Scanning…';
showScanStatus('Scanning directory, please wait…', 'info');
announce('Scanning directory for video files, please wait.');
// Hide previous results
els.sectionFiles.hidden = true;
try {
const resp = await fetch('/api/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory, min_size_gb: minSize }),
});
const data = await resp.json();
if (!resp.ok) {
showScanStatus(`Error: ${data.error}`, 'error');
announce(`Scan failed: ${data.error}`);
return;
}
state.scannedFiles = data.files;
state.selectedPaths.clear();
if (data.files.length === 0) {
showScanStatus(
`No video files larger than ${minSize} GB found in that directory.`,
'warn'
);
announce('No video files found matching your criteria.');
return;
}
showScanStatus(`Found ${data.files.length} file(s).`, 'success');
announce(`Scan complete. Found ${data.files.length} video files.`);
renderFileTable(data.files);
els.sectionFiles.hidden = false;
els.sectionFiles.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (err) {
showScanStatus(`Network error: ${err.message}`, 'error');
announce(`Scan error: ${err.message}`);
} finally {
els.scanBtn.disabled = false;
els.scanBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⊙</span> Scan for Files';
}
});
function showScanStatus(msg, type) {
els.scanStatus.textContent = msg;
els.scanStatus.style.color = type === 'error' ? 'var(--text-danger)'
: type === 'success' ? 'var(--text-success)'
: type === 'warn' ? 'var(--accent)'
: 'var(--text-muted)';
}
// ─── File Table Rendering ─────────────────────────────────────────────────────
function renderFileTable(files) {
let html = '';
files.forEach((f, idx) => {
const sizeFmt = f.size_gb.toFixed(3);
const curBitrate = f.bit_rate_mbps ? `${f.bit_rate_mbps} Mbps` : 'Unknown';
const tgtBitrate = f.target_bit_rate_mbps ? `${f.target_bit_rate_mbps} Mbps` : '—';
const codec = f.codec || 'unknown';
const pathDir = f.path.replace(f.name, '');
html += `
<tr id="row-${idx}" data-path="${escHtml(f.path)}">
<td class="col-check">
<input
type="checkbox"
class="file-checkbox"
id="chk-${idx}"
data-path="${escHtml(f.path)}"
aria-label="Select ${escHtml(f.name)} for compression"
/>
</td>
<td class="col-name">
<label for="chk-${idx}" class="file-name-cell" style="cursor:pointer">
<span class="file-name">${escHtml(f.name)}</span>
<span class="file-path" title="${escHtml(f.path)}">${escHtml(pathDir)}</span>
</label>
</td>
<td class="col-size">
<strong>${sizeFmt}</strong> GB
</td>
<td class="col-bitrate">
<span class="bitrate-badge">${escHtml(curBitrate)}</span>
</td>
<td class="col-target">
<span class="bitrate-badge target">${escHtml(tgtBitrate)}</span>
</td>
<td class="col-codec">
<span class="codec-tag">${escHtml(codec)}</span>
</td>
</tr>`;
});
els.fileTbody.innerHTML = html;
// Attach change events
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.addEventListener('change', () => {
const path = chk.dataset.path;
const row = chk.closest('tr');
if (chk.checked) {
state.selectedPaths.add(path);
row.classList.add('selected');
} else {
state.selectedPaths.delete(path);
row.classList.remove('selected');
}
updateSelectionUI();
});
});
updateSelectionUI();
}
function updateSelectionUI() {
const total = state.scannedFiles.length;
const sel = state.selectedPaths.size;
els.selectionSummary.textContent = `${sel} of ${total} selected`;
els.compressBtn.disabled = sel === 0;
}
els.selectAllBtn.addEventListener('click', () => {
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.checked = true;
state.selectedPaths.add(chk.dataset.path);
chk.closest('tr').classList.add('selected');
});
updateSelectionUI();
announce(`All ${state.scannedFiles.length} files selected.`);
});
els.deselectAllBtn.addEventListener('click', () => {
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
chk.checked = false;
chk.closest('tr').classList.remove('selected');
});
state.selectedPaths.clear();
updateSelectionUI();
announce('All files deselected.');
});
// ─── Start Compression ────────────────────────────────────────────────────────
els.compressBtn.addEventListener('click', async () => {
const selectedFiles = state.scannedFiles.filter(f => state.selectedPaths.has(f.path));
if (selectedFiles.length === 0) return;
const suffix = els.suffixInput.value.trim() || '_new';
const payload = {
files: selectedFiles.map(f => ({
path: f.path,
size_bytes: f.size_bytes,
target_bit_rate_bps: f.target_bit_rate_bps || 1000000,
})),
suffix,
};
els.compressBtn.disabled = true;
els.compressBtn.textContent = 'Starting…';
try {
const resp = await fetch('/api/compress/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok) {
alert(`Failed to start compression: ${data.error}`);
els.compressBtn.disabled = false;
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
return;
}
state.currentJobId = data.job_id;
state.compressionResults = [];
// Show progress section
setupProgressSection(selectedFiles);
els.sectionProgress.hidden = false;
els.sectionProgress.scrollIntoView({ behavior: 'smooth', block: 'start' });
announce(`Compression started for ${selectedFiles.length} file(s).`);
// Start SSE stream
startProgressStream(data.job_id, selectedFiles);
} catch (err) {
alert(`Error: ${err.message}`);
els.compressBtn.disabled = false;
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
}
});
// ─── Progress Setup ───────────────────────────────────────────────────────────
function setupProgressSection(files) {
els.progTotal.textContent = files.length;
els.progDone.textContent = '0';
els.progStatus.textContent = 'Running';
setOverallProgress(0);
// Create per-file items
let html = '';
files.forEach((f, idx) => {
html += `
<div class="file-progress-item" id="fpi-${idx}" role="listitem"
aria-label="File ${idx+1} of ${files.length}: ${escHtml(f.name)}">
<div class="fp-header">
<span class="fp-name">${escHtml(f.name)}</span>
<span class="fp-status waiting" id="fps-${idx}" aria-live="polite">Waiting</span>
</div>
<div class="fp-bar-wrap" aria-label="Progress for ${escHtml(f.name)}">
<div class="fp-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"
id="fpbar-${idx}" aria-label="${escHtml(f.name)} progress">
<div class="fp-bar-fill" id="fpfill-${idx}" style="width:0%"></div>
</div>
<span class="fp-pct" id="fppct-${idx}" aria-hidden="true">0%</span>
</div>
<div class="fp-detail" id="fpdetail-${idx}"></div>
</div>`;
});
els.fileProgressList.innerHTML = html;
}
function setOverallProgress(pct) {
const p = Math.round(pct);
els.overallBarFill.style.width = `${p}%`;
els.overallBar.setAttribute('aria-valuenow', p);
els.overallPct.textContent = `${p}%`;
}
function updateFileProgress(idx, pct, statusClass, statusText, detail, detailClass) {
const fill = $(`fpfill-${idx}`);
const bar = $(`fpbar-${idx}`);
const pctEl = $(`fppct-${idx}`);
const status = $(`fps-${idx}`);
const item = $(`fpi-${idx}`);
const det = $(`fpdetail-${idx}`);
if (!fill) return;
const p = Math.round(pct);
fill.style.width = `${p}%`;
bar.setAttribute('aria-valuenow', p);
pctEl.textContent = `${p}%`;
status.className = `fp-status ${statusClass}`;
status.textContent = statusText;
item.className = `file-progress-item ${statusClass}`;
// Toggle animation on bar fill
fill.classList.toggle('active', statusClass === 'running');
if (detail !== undefined) {
det.textContent = detail;
det.className = `fp-detail ${detailClass || ''}`;
}
}
// ─── SSE Stream Handling ──────────────────────────────────────────────────────
function startProgressStream(jobId, files) {
if (state.eventSource) {
state.eventSource.close();
}
state.eventSource = new EventSource(`/api/compress/progress/${jobId}`);
let doneCount = 0;
state.eventSource.onmessage = (evt) => {
let data;
try { data = JSON.parse(evt.data); }
catch { return; }
switch (data.type) {
case 'start':
els.progStatus.textContent = 'Running';
break;
case 'file_start':
updateFileProgress(data.index, 0, 'running', 'Compressing…', '', '');
// Scroll to active item
const activeItem = $(`fpi-${data.index}`);
if (activeItem) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
announce(`Compressing file ${data.index + 1} of ${data.total}: ${files[data.index]?.name || ''}`);
break;
case 'progress': {
const pct = data.percent || 0;
let detail = '';
if (data.elapsed_secs > 0 && data.duration_secs > 0) {
detail = `${fmtTime(data.elapsed_secs)} / ${fmtTime(data.duration_secs)}`;
}
updateFileProgress(data.index, pct, 'running', 'Compressing…', detail, '');
// Update overall progress
const overallPct = ((doneCount + (pct / 100)) / files.length) * 100;
setOverallProgress(overallPct);
break;
}
case 'file_done': {
doneCount++;
els.progDone.textContent = doneCount;
const detail = data.reduction_pct
? `Saved ${data.reduction_pct}% → ${data.output_size_gb} GB`
: 'Complete';
updateFileProgress(data.index, 100, 'done', '✓ Done', detail, 'success');
setOverallProgress((doneCount / files.length) * 100);
state.compressionResults.push({ ...data, status: 'done' });
announce(`File complete: ${files[data.index]?.name}. Saved ${data.reduction_pct}%.`);
break;
}
case 'file_error': {
doneCount++;
els.progDone.textContent = doneCount;
updateFileProgress(data.index, 0, 'error', '✗ Error', data.message, 'error');
state.compressionResults.push({ ...data, status: 'error' });
announce(`Error compressing file ${files[data.index]?.name}: ${data.message}`);
break;
}
case 'done':
state.eventSource.close();
els.progStatus.textContent = 'Complete';
setOverallProgress(100);
els.cancelBtn.disabled = true;
announce('All compression operations complete.');
showResults('done');
break;
case 'cancelled':
state.eventSource.close();
els.progStatus.textContent = 'Cancelled';
announce('Compression cancelled.');
showResults('cancelled');
break;
case 'error':
state.eventSource.close();
els.progStatus.textContent = 'Error';
announce(`Compression error: ${data.message}`);
break;
}
};
state.eventSource.onerror = () => {
if (state.eventSource.readyState === EventSource.CLOSED) return;
console.error('SSE connection error');
};
}
// ─── Cancel ───────────────────────────────────────────────────────────────────
els.cancelBtn.addEventListener('click', async () => {
if (!state.currentJobId) return;
const confirmed = window.confirm(
'Cancel all compression operations? Any files currently being processed will be deleted.'
);
if (!confirmed) return;
els.cancelBtn.disabled = true;
els.cancelBtn.textContent = 'Cancelling…';
try {
await fetch(`/api/compress/cancel/${state.currentJobId}`, { method: 'POST' });
announce('Cancellation requested.');
} catch (err) {
console.error('Cancel error:', err);
}
});
// ─── Results ──────────────────────────────────────────────────────────────────
function showResults(finalStatus) {
const results = state.compressionResults;
let html = '';
if (finalStatus === 'cancelled') {
html += `<p style="color:var(--text-muted); margin-bottom: var(--space-md)">
Compression was cancelled. Any completed files are listed below.
</p>`;
}
if (results.length === 0 && finalStatus === 'cancelled') {
html += '<p style="color:var(--text-muted)">No files were completed before cancellation.</p>';
}
results.forEach(r => {
if (r.status === 'done') {
html += `
<div class="result-row">
<span class="result-icon"></span>
<div class="result-info">
<div class="result-name">${escHtml(r.filename)}</div>
<div class="result-meta"> ${escHtml(r.output || '')}</div>
</div>
<span class="result-reduction">-${r.reduction_pct}%</span>
</div>`;
} else if (r.status === 'error') {
html += `
<div class="result-row">
<span class="result-icon"></span>
<div class="result-info">
<div class="result-name">${escHtml(r.filename)}</div>
<div class="result-meta" style="color:var(--text-danger)">${escHtml(r.message)}</div>
</div>
</div>`;
}
});
if (html === '') {
html = '<p style="color:var(--text-muted)">No results to display.</p>';
}
els.resultsContent.innerHTML = html;
els.sectionResults.hidden = false;
els.sectionResults.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ─── Restart ──────────────────────────────────────────────────────────────────
els.restartBtn.addEventListener('click', () => {
// Reset state
state.scannedFiles = [];
state.selectedPaths.clear();
state.currentJobId = null;
state.compressionResults = [];
if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
// Reset UI
els.sectionFiles.hidden = true;
els.sectionProgress.hidden = true;
els.sectionResults.hidden = true;
els.fileTbody.innerHTML = '';
els.fileProgressList.innerHTML = '';
els.scanStatus.textContent = '';
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
els.compressBtn.disabled = true;
els.cancelBtn.disabled = false;
els.cancelBtn.textContent = '✕ Cancel Compression';
// Scroll to top
document.getElementById('section-config').scrollIntoView({ behavior: 'smooth' });
els.dirInput.focus();
announce('Session reset. Ready to scan again.');
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function fmtTime(seconds) {
const s = Math.floor(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
return `${m}:${pad(sec)}`;
}
function pad(n) {
return String(n).padStart(2, '0');
}
// ─── Init ─────────────────────────────────────────────────────────────────────
initTheme();

486
app.py Normal file
View file

@ -0,0 +1,486 @@
#!/usr/bin/env python3
"""
Video Compressor Web Application WSGI/Docker edition
See wsgi.py for the Gunicorn entry point.
"""
import os
import json
import subprocess
import threading
import time
from pathlib import Path
from flask import Flask, request, jsonify, Response, render_template, stream_with_context
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
BASE_DIR = Path(__file__).resolve().parent
MEDIA_ROOT = Path(os.environ.get('MEDIA_ROOT', '/media')).resolve()
app = Flask(
__name__,
template_folder=str(BASE_DIR / 'templates'),
static_folder=str(BASE_DIR / 'static'),
)
active_jobs: dict = {}
job_lock = threading.Lock()
# ---------------------------------------------------------------------------
# Path-jail helper — every client-supplied path passes through this
# ---------------------------------------------------------------------------
def safe_path(raw: str) -> Path:
"""
Resolve *raw* and assert it is inside MEDIA_ROOT.
Raises PermissionError on any attempt to escape the root.
"""
try:
resolved = Path(raw).resolve()
except Exception:
raise PermissionError(f"Invalid path: {raw!r}")
root_str = str(MEDIA_ROOT)
path_str = str(resolved)
if path_str != root_str and not path_str.startswith(root_str + os.sep):
raise PermissionError(
f"Access denied: path is outside the allowed media root ({MEDIA_ROOT})."
)
return resolved
# ---------------------------------------------------------------------------
# FFprobe / filesystem helpers
# ---------------------------------------------------------------------------
VIDEO_EXTENSIONS = {
'.mp4', '.mkv', '.mov', '.avi', '.wmv', '.flv',
'.webm', '.m4v', '.mpg', '.mpeg', '.ts', '.mts',
'.m2ts', '.vob', '.ogv', '.3gp', '.3g2',
}
def get_video_info(filepath: str) -> dict | None:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'format=duration,bit_rate,size:stream=codec_name,width,height',
'-of', 'json',
filepath,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
return None
data = json.loads(result.stdout)
fmt = data.get('format', {})
stream = (data.get('streams') or [{}])[0]
duration = float(fmt.get('duration', 0))
bit_rate = int(fmt.get('bit_rate', 0))
size_bytes = int(fmt.get('size', 0))
codec = stream.get('codec_name', 'unknown')
width = stream.get('width', 0)
height = stream.get('height', 0)
if bit_rate == 0 and duration > 0:
bit_rate = int((size_bytes * 8) / duration)
audio_bps = 128_000
video_bps = bit_rate - audio_bps if bit_rate > audio_bps else bit_rate
target_video_bps = max(int(video_bps / 3), 200_000)
return {
'duration': duration,
'bit_rate_bps': bit_rate,
'bit_rate_mbps': round(bit_rate / 1_000_000, 2),
'target_bit_rate_bps': target_video_bps,
'target_bit_rate_mbps': round(target_video_bps / 1_000_000, 2),
'size_bytes': size_bytes,
'size_gb': round(size_bytes / (1024 ** 3), 3),
'codec': codec,
'width': width,
'height': height,
}
except Exception:
return None
def list_video_files(directory: Path, min_size_gb: float) -> list:
min_bytes = min_size_gb * (1024 ** 3)
results = []
try:
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if not d.startswith('.')]
for fname in files:
if Path(fname).suffix.lower() in VIDEO_EXTENSIONS:
fpath = os.path.join(root, fname)
try:
fsize = os.path.getsize(fpath)
if fsize >= min_bytes:
results.append({
'path': fpath,
'name': fname,
'size_bytes': fsize,
'size_gb': round(fsize / (1024 ** 3), 3),
})
except OSError:
continue
except PermissionError as exc:
raise PermissionError(f"Cannot access directory: {exc}") from exc
return results
# ---------------------------------------------------------------------------
# Routes — UI
# ---------------------------------------------------------------------------
@app.route('/')
def index():
return render_template('index.html', media_root=str(MEDIA_ROOT))
# ---------------------------------------------------------------------------
# Routes — API
# ---------------------------------------------------------------------------
@app.route('/api/config')
def api_config():
"""Expose server-side config the frontend needs (e.g. media root)."""
return jsonify({'media_root': str(MEDIA_ROOT)})
@app.route('/api/browse')
def browse_directory():
raw = request.args.get('path', str(MEDIA_ROOT))
try:
path = safe_path(raw)
except PermissionError as exc:
return jsonify({'error': str(exc)}), 403
if not path.exists():
return jsonify({'error': 'Path does not exist'}), 404
if not path.is_dir():
return jsonify({'error': 'Not a directory'}), 400
try:
entries = []
for entry in sorted(path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if entry.name.startswith('.'):
continue
entries.append({'name': entry.name, 'path': str(entry), 'is_dir': entry.is_dir()})
parent = str(path.parent) if path != MEDIA_ROOT else None
return jsonify({
'current': str(path),
'parent': parent,
'entries': entries,
'media_root': str(MEDIA_ROOT),
})
except PermissionError:
return jsonify({'error': 'Permission denied'}), 403
@app.route('/api/scan', methods=['POST'])
def scan_directory():
data = request.get_json(silent=True) or {}
raw_dir = data.get('directory', '')
min_size_gb = float(data.get('min_size_gb', 1.0))
if not raw_dir:
return jsonify({'error': 'No directory provided'}), 400
try:
directory = safe_path(raw_dir)
except PermissionError as exc:
return jsonify({'error': str(exc)}), 403
if not directory.is_dir():
return jsonify({'error': 'Invalid directory'}), 400
try:
files = list_video_files(directory, min_size_gb)
except PermissionError as exc:
return jsonify({'error': str(exc)}), 403
enriched = []
for f in files:
info = get_video_info(f['path'])
if info:
f.update(info)
else:
f['bit_rate_mbps'] = round((f['size_bytes'] * 8) / (90 * 60 * 1_000_000), 2)
f['target_bit_rate_mbps'] = round(f['bit_rate_mbps'] / 3, 2)
f['bit_rate_bps'] = int(f['bit_rate_mbps'] * 1_000_000)
f['target_bit_rate_bps'] = int(f['target_bit_rate_mbps'] * 1_000_000)
f['duration'] = 0
f['codec'] = 'unknown'
f['width'] = 0
f['height'] = 0
enriched.append(f)
enriched.sort(key=lambda x: x['size_bytes'], reverse=True)
return jsonify({'files': enriched, 'count': len(enriched)})
@app.route('/api/compress/start', methods=['POST'])
def start_compression():
data = request.get_json(silent=True) or {}
files = data.get('files', [])
suffix = data.get('suffix', '_new')
if not files:
return jsonify({'error': 'No files provided'}), 400
for f in files:
try:
safe_path(f.get('path', ''))
except PermissionError as exc:
return jsonify({'error': str(exc)}), 403
job_id = f"job_{int(time.time() * 1000)}"
job = {
'id': job_id,
'files': files,
'suffix': suffix,
'status': 'running',
'current_index': 0,
'total': len(files),
'events': [],
'process': None,
'cancelled': False,
'lock': threading.Lock(),
}
with job_lock:
active_jobs[job_id] = job
threading.Thread(target=run_compression_job, args=(job_id,), daemon=True).start()
return jsonify({'job_id': job_id})
@app.route('/api/compress/progress/<job_id>')
def compression_progress(job_id):
"""
SSE stream. Works under Gunicorn+gevent: time.sleep() yields the
greenlet instead of blocking a real OS thread.
"""
def event_stream():
last_idx = 0
while True:
with job_lock:
job = active_jobs.get(job_id)
if not job:
yield f"data: {json.dumps({'type': 'error', 'message': 'Job not found'})}\n\n"
return
with job['lock']:
new_events = job['events'][last_idx:]
last_idx += len(new_events)
status = job['status']
for event in new_events:
yield f"data: {json.dumps(event)}\n\n"
if status in ('done', 'cancelled', 'error') and not new_events:
break
time.sleep(0.25)
return Response(
stream_with_context(event_stream()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'},
)
@app.route('/api/compress/cancel/<job_id>', methods=['POST'])
def cancel_compression(job_id):
with job_lock:
job = active_jobs.get(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
with job['lock']:
job['cancelled'] = True
proc = job.get('process')
if proc and proc.poll() is None:
try:
proc.terminate()
time.sleep(1)
if proc.poll() is None:
proc.kill()
except Exception:
pass
return jsonify({'status': 'cancellation requested'})
# ---------------------------------------------------------------------------
# Compression worker
# ---------------------------------------------------------------------------
def push_event(job: dict, event: dict) -> None:
with job['lock']:
job['events'].append(event)
def run_compression_job(job_id: str) -> None:
with job_lock:
job = active_jobs.get(job_id)
if not job:
return
files = job['files']
suffix = job['suffix']
total = job['total']
push_event(job, {'type': 'start', 'total': total,
'message': f'Starting compression of {total} file(s)'})
for idx, file_info in enumerate(files):
with job['lock']:
cancelled = job['cancelled']
if cancelled:
push_event(job, {'type': 'cancelled', 'message': 'Compression cancelled by user'})
with job['lock']:
job['status'] = 'cancelled'
return
src_path = file_info['path']
target_bitrate = file_info.get('target_bit_rate_bps', 1_000_000)
p = Path(src_path)
out_path = str(p.parent / (p.stem + suffix + p.suffix))
push_event(job, {
'type': 'file_start', 'index': idx, 'total': total,
'filename': p.name, 'output': out_path,
'message': f'Compressing ({idx + 1}/{total}): {p.name}',
})
try:
probe = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', src_path],
capture_output=True, text=True, timeout=30,
)
duration_secs = float(probe.stdout.strip()) if probe.stdout.strip() else 0
except Exception:
duration_secs = 0
video_k = max(int(target_bitrate / 1000), 200)
cmd = [
'ffmpeg', '-y', '-i', src_path,
'-c:v', 'libx264',
'-b:v', f'{video_k}k',
'-maxrate', f'{int(video_k * 1.5)}k',
'-bufsize', f'{video_k * 2}k',
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
'-progress', 'pipe:1', '-nostats',
out_path,
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1)
with job['lock']:
job['process'] = proc
for line in proc.stdout:
with job['lock']:
cancelled = job['cancelled']
if cancelled:
proc.terminate()
break
line = line.strip()
if '=' not in line:
continue
key, _, value = line.partition('=')
key, value = key.strip(), value.strip()
if key == 'out_time_ms' and duration_secs > 0:
try:
elapsed = int(value) / 1_000_000
pct = min(100.0, (elapsed / duration_secs) * 100)
push_event(job, {
'type': 'progress', 'index': idx,
'percent': round(pct, 1),
'elapsed_secs': round(elapsed, 1),
'duration_secs': round(duration_secs, 1),
})
except (ValueError, ZeroDivisionError):
pass
elif key == 'progress' and value == 'end':
push_event(job, {
'type': 'progress', 'index': idx,
'percent': 100.0,
'elapsed_secs': duration_secs,
'duration_secs': duration_secs,
})
proc.wait()
with job['lock']:
cancelled = job['cancelled']
if cancelled:
try:
if os.path.exists(out_path):
os.remove(out_path)
except OSError:
pass
push_event(job, {'type': 'cancelled', 'message': 'Compression cancelled by user'})
with job['lock']:
job['status'] = 'cancelled'
return
if proc.returncode != 0:
try:
tail = proc.stderr.read()[-500:]
except Exception:
tail = ''
push_event(job, {
'type': 'file_error', 'index': idx, 'filename': p.name,
'message': f'ffmpeg exited with code {proc.returncode}',
'detail': tail,
})
else:
try:
out_sz = os.path.getsize(out_path)
out_gb = round(out_sz / (1024 ** 3), 3)
orig_sz = file_info.get('size_bytes', 0)
reduction = round((1 - out_sz / orig_sz) * 100, 1) if orig_sz else 0
except OSError:
out_gb = 0
reduction = 0
push_event(job, {
'type': 'file_done', 'index': idx,
'filename': p.name, 'output': out_path,
'output_size_gb': out_gb, 'reduction_pct': reduction,
'message': f'Completed: {p.name} → saved {reduction}%',
})
with job['lock']:
job['current_index'] = idx + 1
except Exception as exc:
push_event(job, {
'type': 'file_error', 'index': idx,
'filename': p.name, 'message': f'Exception: {exc}',
})
push_event(job, {'type': 'done', 'message': f'All {total} file(s) processed.'})
with job['lock']:
job['status'] = 'done'
# ---------------------------------------------------------------------------
# Dev-server entry point (production: gunicorn -c gunicorn.conf.py wsgi:app)
# ---------------------------------------------------------------------------
if __name__ == '__main__':
import sys
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5000
print(f"\n{'='*60}")
print(f" VideoPress — dev server http://localhost:{port}")
print(f" MEDIA_ROOT : {MEDIA_ROOT}")
print(f"{'='*60}\n")
app.run(host='0.0.0.0', port=port, debug=False, threaded=True)

85
docker-compose.yml Normal file
View file

@ -0,0 +1,85 @@
# =============================================================================
# VideoPress — docker-compose.yml
# =============================================================================
#
# Quick start:
# 1. Edit MEDIA_HOST_PATH below (or set it as a shell variable before running)
# 2. docker compose up -d
# 3. Open http://localhost:8080
#
# All configuration lives in the 'environment' section — no .env file needed
# for basic usage, though a .env file is supported (see comments below).
# =============================================================================
services:
videopress:
build:
context: .
dockerfile: Dockerfile
# ── Alternatively, use a pre-built image: ───────────────────────────────
# image: videopress:latest
container_name: videopress
restart: unless-stopped
# ── Port mapping ─────────────────────────────────────────────────────────
# Format: "HOST_PORT:CONTAINER_PORT"
# The UI and REST API are served on the same port — no separate API port
# is required. Change the host port (left side) as needed.
ports:
- "8080:8080"
# ── Volume mapping ────────────────────────────────────────────────────────
# Map the directory on your HOST that contains the video files into the
# container at /media (MEDIA_ROOT).
#
# *** Change /path/to/your/videos to the real path on your host. ***
#
# You can also set MEDIA_HOST_PATH as an environment variable before
# running docker compose:
# export MEDIA_HOST_PATH=/mnt/nas/videos && docker compose up -d
volumes:
- ${MEDIA_HOST_PATH:-/path/to/your/videos}:/media
# ── Environment variables ─────────────────────────────────────────────────
environment:
# Path *inside the container* where videos are accessible.
# Must match the right-hand side of the volume mount above.
MEDIA_ROOT: /media
# TCP port Gunicorn listens on (must match EXPOSE in Dockerfile and
# the right-hand side of the ports mapping above).
PORT: 8080
# Gunicorn log level: debug | info | warning | error | critical
LOG_LEVEL: info
# ── Resource limits (optional — uncomment to enable) ─────────────────────
# Compressing large video files is CPU-intensive. Limits prevent the
# container from starving other workloads on the host.
# deploy:
# resources:
# limits:
# cpus: '4'
# memory: 2G
# reservations:
# cpus: '1'
# memory: 512M
# ── Health check ──────────────────────────────────────────────────────────
healthcheck:
test: ["CMD", "python3", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8080/')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
# ── Logging ───────────────────────────────────────────────────────────────
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

39
gunicorn.conf.py Normal file
View file

@ -0,0 +1,39 @@
"""
gunicorn.conf.py Gunicorn configuration for VideoPress.
Key choices:
worker_class = gevent
Gevent workers are required for Server-Sent Events (SSE). A standard
sync worker would block on the SSE generator and starve other requests.
Gevent patches blocking I/O so each SSE stream is handled as a
lightweight greenlet, not a full OS thread.
workers = 1
FFmpeg compression jobs are stored in an in-process dict (active_jobs).
Multiple worker *processes* would not share that state. Keep workers=1
and let gevent's concurrency handle multiple simultaneous HTTP clients.
If you need multi-process resilience, replace the in-process job store
with Redis or a database.
timeout = 0
SSE streams are long-lived; the default 30-second worker timeout would
kill them. Setting timeout=0 disables the timeout entirely for gevent
workers (gevent uses its own internal greenlet scheduling).
"""
import os
bind = f"0.0.0.0:{os.environ.get('PORT', '8080')}"
workers = 1 # see note above — must stay at 1
worker_class = 'gevent'
worker_connections = 100 # max simultaneous greenlets per worker
timeout = 0 # disable worker timeout for SSE streams
keepalive = 5
loglevel = os.environ.get('LOG_LEVEL', 'info')
accesslog = '-' # stdout
errorlog = '-' # stderr
capture_output = True
# Forward the real client IP when behind a reverse proxy (nginx / Traefik)
forwarded_allow_ips = '*'
proxy_allow_ips = '*'

260
index.html Normal file
View file

@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Server-side video compression tool using FFmpeg. Browse, select, and compress large video files." />
<title>VideoPress — FFmpeg Compressor</title>
<link rel="stylesheet" href="/static/css/main.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<!-- Fallback font stack if Google Fonts unavailable -->
</head>
<body>
<!-- Skip navigation for accessibility -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<header class="app-header" role="banner">
<div class="header-inner">
<div class="logo" aria-label="VideoPress FFmpeg Compressor">
<span class="logo-icon" aria-hidden="true"></span>
<span class="logo-text">Video<strong>Press</strong></span>
</div>
<div class="header-actions">
<button
id="theme-toggle"
class="btn-icon"
aria-label="Toggle dark/light mode"
title="Toggle dark/light mode"
>
<span class="theme-icon" aria-hidden="true" id="theme-icon"></span>
</button>
</div>
</div>
</header>
<main id="main-content" class="app-main" role="main">
<!-- STEP 1: Directory & Filter Configuration -->
<section class="card" id="section-config" aria-labelledby="config-heading">
<h2 id="config-heading" class="card-title">
<span class="step-badge" aria-hidden="true">01</span>
Configure Source
</h2>
<div class="config-grid">
<!-- Directory Browser -->
<div class="field-group">
<label for="dir-input" class="field-label">Server Directory</label>
<div class="dir-input-row">
<input
type="text"
id="dir-input"
class="text-input"
placeholder="/path/to/videos"
value="/"
aria-describedby="dir-hint"
autocomplete="off"
spellcheck="false"
/>
<button class="btn btn-secondary" id="browse-btn" aria-label="Browse server directories">
Browse
</button>
</div>
<p id="dir-hint" class="field-hint">Enter or browse to a directory on the server where videos are stored.</p>
</div>
<!-- Minimum File Size -->
<div class="field-group">
<label for="min-size-input" class="field-label">
Minimum File Size
<span class="field-unit">(GB)</span>
</label>
<input
type="number"
id="min-size-input"
class="text-input"
min="0.1"
max="1000"
step="0.1"
value="1.0"
aria-describedby="size-hint"
/>
<p id="size-hint" class="field-hint">Only files larger than this threshold will be listed.</p>
</div>
<!-- Suffix -->
<div class="field-group">
<label for="suffix-input" class="field-label">
Output Filename Suffix
</label>
<input
type="text"
id="suffix-input"
class="text-input"
value="_new"
placeholder="_new"
aria-describedby="suffix-hint"
maxlength="64"
/>
<p id="suffix-hint" class="field-hint">
Appended before the file extension. E.g. <code>movie_new.mp4</code>
</p>
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary btn-lg" id="scan-btn" aria-describedby="scan-status">
<span class="btn-icon-prefix" aria-hidden="true"></span>
Scan for Files
</button>
<span id="scan-status" class="status-text" aria-live="polite" aria-atomic="true"></span>
</div>
</section>
<!-- Directory Browser Modal -->
<div
id="browser-modal"
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="browser-modal-title"
hidden
>
<div class="modal-panel">
<div class="modal-header">
<h3 id="browser-modal-title" class="modal-title">Browse Server Directory</h3>
<button class="btn-icon" id="close-browser" aria-label="Close directory browser"></button>
</div>
<div class="browser-path-bar">
<span class="browser-path-label" aria-label="Current path:">
<span id="browser-current-path" aria-live="polite">/</span>
</span>
</div>
<div id="browser-list" class="browser-list" role="listbox" aria-label="Directory contents" tabindex="0">
<p class="browser-loading">Loading…</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="browser-cancel">Cancel</button>
<button class="btn btn-primary" id="browser-select">Select This Directory</button>
</div>
</div>
</div>
<!-- STEP 2: File List -->
<section class="card" id="section-files" aria-labelledby="files-heading" hidden>
<h2 id="files-heading" class="card-title">
<span class="step-badge" aria-hidden="true">02</span>
Select Files to Compress
</h2>
<div class="files-toolbar" role="toolbar" aria-label="File selection controls">
<div class="toolbar-left">
<button class="btn btn-sm btn-outline" id="select-all-btn" aria-label="Mark all files for compression">
☑ Select All
</button>
<button class="btn btn-sm btn-outline" id="deselect-all-btn" aria-label="Unmark all files">
☐ Deselect All
</button>
</div>
<div class="toolbar-right">
<span id="selection-summary" class="selection-summary" aria-live="polite" aria-atomic="true">
0 of 0 selected
</span>
</div>
</div>
<div class="table-wrapper" role="region" aria-label="Video files list" tabindex="0">
<table class="file-table" id="file-table" aria-describedby="files-heading">
<thead>
<tr>
<th scope="col" class="col-check">
<span class="sr-only">Select</span>
</th>
<th scope="col" class="col-name">File Name</th>
<th scope="col" class="col-size">Size (GB)</th>
<th scope="col" class="col-bitrate">Current Bitrate</th>
<th scope="col" class="col-target">Target Bitrate</th>
<th scope="col" class="col-codec">Codec</th>
</tr>
</thead>
<tbody id="file-tbody">
</tbody>
</table>
</div>
<div class="card-footer">
<button class="btn btn-primary btn-lg" id="compress-btn" disabled>
<span class="btn-icon-prefix" aria-hidden="true"></span>
Compress Selected Files
</button>
</div>
</section>
<!-- STEP 3: Compression Progress -->
<section class="card" id="section-progress" aria-labelledby="progress-heading" hidden>
<h2 id="progress-heading" class="card-title">
<span class="step-badge" aria-hidden="true">03</span>
Compression Progress
</h2>
<div class="progress-overview" role="region" aria-label="Overall progress">
<div class="overview-stats">
<div class="stat-chip">
<span class="stat-label">Total Files</span>
<span class="stat-value" id="prog-total"></span>
</div>
<div class="stat-chip">
<span class="stat-label">Completed</span>
<span class="stat-value" id="prog-done">0</span>
</div>
<div class="stat-chip">
<span class="stat-label">Status</span>
<span class="stat-value" id="prog-status" aria-live="polite">Waiting</span>
</div>
</div>
<div class="overall-bar-wrap" aria-label="Overall compression progress">
<div class="overall-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" id="overall-bar">
<div class="overall-bar-fill" id="overall-bar-fill" style="width:0%"></div>
</div>
<span class="overall-pct" id="overall-pct" aria-hidden="true">0%</span>
</div>
</div>
<div id="file-progress-list" class="file-progress-list" aria-label="Individual file progress" role="list">
</div>
<div class="card-footer">
<button class="btn btn-danger" id="cancel-btn" aria-label="Cancel all compression operations">
✕ Cancel Compression
</button>
</div>
</section>
<!-- Results summary (shown after done/cancelled) -->
<section class="card" id="section-results" aria-labelledby="results-heading" hidden>
<h2 id="results-heading" class="card-title">
<span class="step-badge" aria-hidden="true">04</span>
Results
</h2>
<div id="results-content" class="results-content"></div>
<div class="card-footer">
<button class="btn btn-secondary" id="restart-btn">
↺ Start New Session
</button>
</div>
</section>
</main>
<footer class="app-footer" role="contentinfo">
<p>VideoPress uses <strong>FFmpeg</strong> for video compression. Files are processed on the server.</p>
</footer>
<!-- Live region for screen reader announcements -->
<div id="sr-announce" class="sr-only" aria-live="assertive" aria-atomic="true"></div>
<script src="/static/js/app.js"></script>
</body>
</html>

1231
main.css Normal file

File diff suppressed because it is too large Load diff

12
requirements.txt Normal file
View file

@ -0,0 +1,12 @@
# VideoPress — Python dependencies
# Install with: pip install -r requirements.txt
# Web framework
Flask==3.1.0
# WSGI server (production)
gunicorn==23.0.0
# Async worker for SSE / long-lived streaming responses
gevent==24.11.1
greenlet==3.1.1

60
start.sh Normal file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env bash
# =============================================================================
# VideoPress — startup script
# Usage: ./start.sh [--prod] [PORT]
# =============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODE="dev"
PORT=""
for arg in "$@"; do
case "$arg" in
--prod) MODE="prod" ;;
--help|-h)
echo "Usage: $0 [--prod] [PORT]"
echo " --prod Run via Gunicorn+gevent (production)"
echo " PORT Port number (default: 5000 dev / 8080 prod)"
exit 0 ;;
[0-9]*) PORT="$arg" ;;
esac
done
[[ -z "$PORT" ]] && PORT=$( [[ "$MODE" == "prod" ]] && echo "8080" || echo "5000" )
echo ""
echo "============================================================"
echo " VideoPress | mode: $MODE | port: $PORT"
echo "============================================================"
echo ""
check() { command -v "$1" &>/dev/null || { echo "ERROR: $1 not found. $2"; exit 1; }; }
check python3 "Install Python 3.8+"
check ffmpeg "sudo apt install ffmpeg"
check ffprobe "Bundled with ffmpeg"
python3 -c "import flask" 2>/dev/null || { echo "ERROR: Flask missing — pip install -r requirements.txt"; exit 1; }
echo " ✓ Python $(python3 --version)"
echo " ✓ Flask $(python3 -c 'import flask; print(flask.__version__)')"
echo " ✓ FFmpeg available"
echo " MEDIA_ROOT: ${MEDIA_ROOT:-/media (default)}"
echo ""
cd "$SCRIPT_DIR"
if [[ "$MODE" == "prod" ]]; then
python3 -c "import gunicorn" 2>/dev/null || { echo "ERROR: gunicorn missing — pip install -r requirements.txt"; exit 1; }
python3 -c "import gevent" 2>/dev/null || { echo "ERROR: gevent missing — pip install -r requirements.txt"; exit 1; }
echo " Starting Gunicorn on http://0.0.0.0:${PORT}"
echo " Press Ctrl+C to stop."
echo "============================================================"
echo ""
PORT="$PORT" exec gunicorn -c gunicorn.conf.py wsgi:app
else
echo " WARNING: Dev server only — use --prod or Docker for production."
echo " Starting Flask on http://localhost:${PORT}"
echo "============================================================"
echo ""
exec python3 app.py "$PORT"
fi

18
wsgi.py Normal file
View file

@ -0,0 +1,18 @@
"""
wsgi.py Gunicorn entry point for VideoPress.
Start with:
gunicorn -c gunicorn.conf.py wsgi:app
Or directly:
gunicorn \
--worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker \
--workers 1 \
--bind 0.0.0.0:8080 \
wsgi:app
"""
from app import app # noqa: F401 — 'app' is the Flask application object
# Gunicorn imports this module and looks for a callable named 'app'.
# Nothing else is needed here.