GMVideoFrames / index.html
vivekchakraverty's picture
Update index.html
187ce16 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Frame Extractor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ── Design Tokens ── */
:root {
--color-bg: #f8f9fb;
--color-surface: #ffffff;
--color-surface-alt: #f1f3f9;
--color-border: #e2e5ee;
--color-border-light: #eef0f5;
--color-text: #1a1d27;
--color-text-secondary: #5c6370;
--color-text-tertiary: #9099a8;
--color-primary: #4f6ef7;
--color-primary-hover: #3b5ae0;
--color-primary-light: #eef1fe;
--color-primary-ghost: rgba(79,110,247,0.08);
--color-success: #22c55e;
--color-success-light: #ecfdf5;
--color-warning: #f59e0b;
--color-warning-hover: #d97706;
--color-error: #ef4444;
--color-error-light: #fef2f2;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 18px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 2px 8px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.04);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Layout ── */
.app-container {
max-width: 1120px;
margin: 0 auto;
padding: 0 1.5rem 4rem;
}
/* ── Header ── */
.app-header {
padding: 2rem 0 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-logo {
width: 38px;
height: 38px;
background: var(--color-primary);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.app-logo svg { width: 20px; height: 20px; color: #fff; }
.app-header h1 {
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
.app-header .subtitle {
font-size: 0.82rem;
color: var(--color-text-tertiary);
font-weight: 400;
margin-top: 1px;
}
/* ── Card ── */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.card-body { padding: 1.5rem; }
.card + .card { margin-top: 1rem; }
.card-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border-light);
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-header h2 {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text);
letter-spacing: -0.01em;
}
.btn-regenerate {
margin-left: auto;
height: 32px;
padding: 0 0.75rem;
font-size: 0.78rem;
}
.card-header .badge {
font-size: 0.72rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 20px;
background: var(--color-primary-light);
color: var(--color-primary);
}
.card-icon {
width: 18px;
height: 18px;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
/* ── Upload Section ── */
.upload-section { margin-top: 0; }
.drop-zone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-alt);
padding: 2.5rem 1.5rem;
text-align: center;
cursor: pointer;
transition: all 0.25s var(--ease-out);
position: relative;
overflow: hidden;
}
.drop-zone::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), var(--color-primary-ghost), transparent 40%);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.drop-zone:hover::before { opacity: 1; }
.drop-zone:hover,
.drop-zone:focus-visible {
border-color: var(--color-primary);
background: var(--color-primary-light);
outline: none;
}
.drop-zone.dragover {
border-color: var(--color-primary);
background: var(--color-primary-light);
transform: scale(1.005);
box-shadow: 0 0 0 4px var(--color-primary-ghost);
}
.drop-zone.has-file {
border-style: solid;
border-color: var(--color-success);
background: var(--color-success-light);
}
.drop-icon {
width: 44px;
height: 44px;
margin: 0 auto 0.75rem;
color: var(--color-text-tertiary);
transition: color 0.2s, transform 0.3s var(--ease-spring);
}
.drop-zone:hover .drop-icon,
.drop-zone.dragover .drop-icon {
color: var(--color-primary);
transform: translateY(-2px);
}
.drop-zone.has-file .drop-icon { color: var(--color-success); }
.drop-label {
font-size: 0.92rem;
font-weight: 500;
color: var(--color-text);
margin-bottom: 0.25rem;
}
.drop-sublabel {
font-size: 0.8rem;
color: var(--color-text-tertiary);
}
.drop-zone.has-file .drop-label { color: var(--color-success); }
.drop-zone input[type="file"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* ── Controls Row ── */
.controls-row {
display: flex;
align-items: flex-end;
gap: 0.75rem;
margin-top: 1rem;
}
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
.form-field label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-secondary);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.form-field input[type="number"] {
height: 40px;
width: 100px;
padding: 0 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.9rem;
font-family: inherit;
color: var(--color-text);
background: var(--color-surface);
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-field input[type="number"]:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-ghost);
}
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
height: 40px;
padding: 0 1.25rem;
border: none;
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s var(--ease-out);
position: relative;
overflow: hidden;
}
.btn::after {
content: '';
position: absolute;
inset: 0;
background: rgba(255,255,255,0.1);
opacity: 0;
transition: opacity 0.15s;
}
.btn:active::after { opacity: 1; }
.btn:active { transform: scale(0.97); }
.btn-primary {
background: var(--color-primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:disabled {
background: #b4bdd4;
cursor: not-allowed;
}
.btn-secondary {
background: var(--color-warning);
color: #fff;
}
.btn-secondary:hover:not(:disabled) { background: var(--color-warning-hover); }
.btn-secondary:disabled { background: #d4c8a4; cursor: not-allowed; }
.btn-ghost {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-surface-alt);
color: var(--color-text);
}
.btn-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* ── Status ── */
.status-bar {
margin-top: 1rem;
min-height: 1.5rem;
}
.status-message {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
padding: 0.5rem 0.85rem;
border-radius: var(--radius-sm);
background: var(--color-surface-alt);
animation: statusIn 0.3s var(--ease-out);
}
.status-message.error {
background: var(--color-error-light);
color: var(--color-error);
}
.status-message.success {
background: var(--color-success-light);
color: #16a34a;
}
.status-message:empty {
display: none;
}
@keyframes statusIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Spinner ── */
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Frame Grid ── */
.frames-section {
animation: sectionIn 0.4s var(--ease-out);
}
@keyframes sectionIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.grid-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--color-border-light);
}
.grid-toolbar-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.grid-toolbar .btn {
height: 32px;
padding: 0 0.75rem;
font-size: 0.78rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
padding: 1.25rem;
}
.grid-item {
position: relative;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: transform 0.2s var(--ease-out), box-shadow 0.2s var(--ease-out);
background: var(--color-surface-alt);
}
.grid-item:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.grid-item img {
width: 100%;
display: block;
transition: transform 0.3s var(--ease-out);
}
.grid-item:hover img { transform: scale(1.03); }
/* Selection overlay */
.grid-item .select-overlay {
position: absolute;
inset: 0;
background: rgba(79,110,247,0.15);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.grid-item.selected .select-overlay { opacity: 1; }
.grid-item .check-mark {
position: absolute;
top: 8px;
right: 8px;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255,255,255,0.85);
border: 2px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s var(--ease-spring);
backdrop-filter: blur(4px);
}
.grid-item .check-mark svg {
width: 14px;
height: 14px;
color: #fff;
opacity: 0;
transform: scale(0.5);
transition: all 0.2s var(--ease-spring);
}
.grid-item.selected .check-mark {
background: var(--color-primary);
border-color: var(--color-primary);
transform: scale(1);
}
.grid-item.selected .check-mark svg {
opacity: 1;
transform: scale(1);
}
.grid-item .frame-index {
position: absolute;
bottom: 6px;
left: 8px;
font-size: 0.68rem;
font-weight: 600;
color: #fff;
background: rgba(0,0,0,0.5);
padding: 1px 6px;
border-radius: 4px;
backdrop-filter: blur(4px);
pointer-events: none;
}
/* Hidden native checkbox */
.grid-item input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Grid item entrance animation */
.grid-item {
animation: frameIn 0.35s var(--ease-out) backwards;
}
@keyframes frameIn {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
/* ── Export Panel ── */
.export-panel { display: none; animation: sectionIn 0.4s var(--ease-out); }
.export-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
@media (max-width: 640px) {
.export-grid { grid-template-columns: 1fr; }
}
.export-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.export-field > label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.export-field select,
.export-field input[type="number"] {
height: 36px;
padding: 0 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 0.85rem;
color: var(--color-text);
background: var(--color-surface);
transition: border-color 0.15s, box-shadow 0.15s;
}
.export-field select:focus,
.export-field input[type="number"]:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-ghost);
}
.export-field input[type="number"] { width: 80px; }
.export-inline {
display: flex;
align-items: center;
gap: 0.5rem;
}
.export-inline input[type="color"] {
width: 36px;
height: 36px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px;
cursor: pointer;
background: var(--color-surface);
transition: border-color 0.15s;
}
.export-inline input[type="color"]:hover {
border-color: var(--color-primary);
}
/* Range slider */
.range-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.range-group input[type="range"] {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--color-border);
border-radius: 2px;
outline: none;
}
.range-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--color-primary);
border-radius: 50%;
cursor: pointer;
transition: transform 0.15s var(--ease-spring);
}
.range-group input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.range-value {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
min-width: 28px;
text-align: right;
}
/* Toggle switch */
.toggle-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.toggle {
position: relative;
width: 38px;
height: 22px;
flex-shrink: 0;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-track {
position: absolute;
inset: 0;
background: var(--color-border);
border-radius: 11px;
cursor: pointer;
transition: background 0.2s;
}
.toggle-track::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
transition: transform 0.2s var(--ease-spring);
}
.toggle input:checked + .toggle-track {
background: var(--color-primary);
}
.toggle input:checked + .toggle-track::after {
transform: translateX(16px);
}
.toggle-label {
font-size: 0.85rem;
color: var(--color-text);
cursor: pointer;
}
#svg-note {
display: none;
font-size: 0.8rem;
color: var(--color-text-tertiary);
padding: 0.6rem 0.85rem;
background: var(--color-surface-alt);
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
line-height: 1.5;
}
.export-divider {
border: none;
border-top: 1px solid var(--color-border-light);
margin: 0.75rem 0;
}
.export-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-top: 0.5rem;
}
.sel-count {
font-size: 0.8rem;
color: var(--color-text-tertiary);
margin-left: auto;
}
/* ── Preview Section ── */
.preview-section {
display: none;
animation: sectionIn 0.4s var(--ease-out);
}
.preview-loading {
display: none;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
font-size: 0.82rem;
color: var(--color-text-tertiary);
}
.preview-container {
border: 1px solid var(--color-border-light);
background: var(--color-surface-alt);
border-radius: var(--radius-md);
padding: 1rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 80px;
transition: opacity 0.3s;
}
#preview-img {
max-width: 100%;
max-height: 400px;
object-fit: contain;
display: block;
border-radius: var(--radius-sm);
transition: opacity 0.3s var(--ease-out);
}
.preview-hint {
font-size: 0.75rem;
color: var(--color-text-tertiary);
margin-top: 0.5rem;
text-align: center;
}
/* ── Empty State ── */
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
color: var(--color-text-tertiary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 0.75rem;
opacity: 0.4;
}
.empty-state p {
font-size: 0.88rem;
}
/* ── Responsive ── */
@media (max-width: 600px) {
.app-container { padding: 0 1rem 3rem; }
.app-header { padding: 1.25rem 0 1rem; }
.app-header h1 { font-size: 1.15rem; }
.controls-row { flex-direction: column; align-items: stretch; }
.form-field input[type="number"] { width: 100%; }
.btn { width: 100%; }
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.5rem; }
.export-actions { flex-direction: column; }
.sel-count { margin-left: 0; }
}
</style>
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="app-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="2.18"/>
<line x1="7" y1="2" x2="7" y2="22"/>
<line x1="17" y1="2" x2="17" y2="22"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
<line x1="17" y1="17" x2="22" y2="17"/>
</svg>
</div>
<div>
<h1>Video Frame Extractor</h1>
<div class="subtitle">Extract, select, and export frames from video files</div>
</div>
</header>
<!-- Upload Card -->
<form id="form" class="card upload-section">
<div class="card-body">
<div class="drop-zone" id="drop-zone" role="button" tabindex="0" aria-label="Drag and drop video file">
<svg class="drop-icon" id="drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p class="drop-label" id="drop-label">Drop your video here, or click to browse</p>
<p class="drop-sublabel" id="drop-sublabel">Supports MP4, MOV, WebM, MKV</p>
<input type="file" id="video" accept=".mp4,.mov,.webm,.mkv" required>
</div>
<div class="controls-row">
<div class="form-field">
<label for="n">Frames</label>
<input type="number" id="n" min="1" value="10" required>
</div>
<button type="submit" id="btn" class="btn btn-primary">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Extract Frames
</button>
</div>
</div>
</form>
<!-- Status -->
<div class="status-bar">
<div id="status" class="status-message"></div>
</div>
<!-- Frames Grid -->
<div id="frames-section" class="frames-section" style="display:none;">
<div class="card">
<div class="card-header">
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
<h2>Extracted Frames</h2>
<span class="badge" id="frame-count-badge">0</span>
</div>
<div class="grid-toolbar">
<div class="grid-toolbar-left">
<button type="button" class="btn btn-ghost" id="select-all-btn">Select All</button>
<button type="button" class="btn btn-ghost" id="deselect-all-btn">Deselect All</button>
</div>
<span id="sel-count" style="font-size:0.8rem; color:var(--color-text-tertiary);">0 selected</span>
</div>
<div class="grid" id="grid"></div>
</div>
</div>
<!-- Export Panel -->
<div class="export-panel card" id="export-panel">
<div class="card-header">
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v12"/>
<path d="m8 11 4 4 4-4"/>
<path d="M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4"/>
</svg>
<h2>Export Options</h2>
</div>
<div class="card-body">
<p id="svg-note">SVG uses filmstrip styling with sprocket holes. Border and spacing options apply to PNG/JPG only.</p>
<div class="export-grid">
<div class="export-field">
<label for="export-format">Format</label>
<select id="export-format">
<option value="png" selected>PNG</option>
<option value="jpg">JPG</option>
<option value="svg">SVG (Filmstrip)</option>
</select>
</div>
<div class="export-field" id="quality-group" style="display:none;">
<label for="export-quality">Quality</label>
<div class="range-group">
<input type="range" id="export-quality" min="1" max="100" value="90">
<span class="range-value" id="quality-value">90</span>
</div>
</div>
</div>
<div id="raster-options">
<hr class="export-divider">
<div class="export-grid">
<div class="export-field">
<label>Frame Borders</label>
<div class="toggle-row">
<label class="toggle" id="border-toggle-label">
<input type="checkbox" id="add-border-chk" checked>
<span class="toggle-track"></span>
</label>
<span class="toggle-label" id="border-toggle-text">Enabled</span>
</div>
</div>
<div class="export-field" id="border-config">
<label>Border</label>
<div class="export-inline">
<input type="number" id="border-width" min="1" max="10" value="2" style="width:60px;">
<span style="font-size:0.8rem;color:var(--color-text-tertiary);">px</span>
<input type="color" id="border-color" value="#c8c8c8">
</div>
</div>
<div class="export-field">
<label>Spacing</label>
<div class="export-inline">
<input type="number" id="frame-spacing" min="0" max="50" value="0" style="width:60px;">
<span style="font-size:0.8rem;color:var(--color-text-tertiary);">px</span>
</div>
</div>
<div class="export-field" id="bg-color-group">
<label>Background Color</label>
<div class="export-inline">
<input type="color" id="bg-color" value="#ffffff">
</div>
</div>
</div>
</div>
<hr class="export-divider">
<div class="export-actions">
<button id="download-export-btn" type="button" class="btn btn-primary" disabled>
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download PNG
</button>
<button id="download-frames-btn" type="button" class="btn btn-secondary" disabled>
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="12" y2="18"/>
<line x1="15" y1="15" x2="12" y2="18"/>
</svg>
Download Selected Frames
</button>
<span class="sel-count" id="sel-count-export">0 frames selected</span>
</div>
</div>
</div>
<!-- Live Preview -->
<div class="preview-section card" id="preview-section">
<div class="card-header">
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<h2>Preview</h2>
<button type="button" class="btn btn-ghost btn-regenerate" id="regenerate-preview-btn" title="Regenerate preview">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Regenerate
</button>
</div>
<div class="card-body">
<div class="preview-loading" id="preview-loading">
<div class="spinner"></div>
Generating preview&hellip;
</div>
<div class="preview-container">
<img id="preview-img" alt="Export preview" />
</div>
<p class="preview-hint">Right-click the image to copy or save directly.</p>
</div>
</div>
<a href="/docs" target="_blank">API Docs</a>
<a href="/redoc" target="_blank">ReDoc</a>
<a href="/openapi.json" target="_blank">OpenAPI JSON</a>
</div><!-- /app-container -->
<script>
const form = document.getElementById("form");
const btn = document.getElementById("btn");
const status = document.getElementById("status");
const grid = document.getElementById("grid");
const framesSection = document.getElementById("frames-section");
const exportPanel = document.getElementById("export-panel");
const previewSection = document.getElementById("preview-section");
const previewImg = document.getElementById("preview-img");
const previewLoading = document.getElementById("preview-loading");
const videoInput = document.getElementById("video");
const dropZone = document.getElementById("drop-zone");
const dropLabel = document.getElementById("drop-label");
const dropSublabel = document.getElementById("drop-sublabel");
const dropIcon = document.getElementById("drop-icon");
const nInput = document.getElementById("n");
const exportFormat = document.getElementById("export-format");
const qualityGroup = document.getElementById("quality-group");
const exportQuality = document.getElementById("export-quality");
const qualityValue = document.getElementById("quality-value");
const svgNote = document.getElementById("svg-note");
const rasterOptions = document.getElementById("raster-options");
const addBorderChk = document.getElementById("add-border-chk");
const borderWidth = document.getElementById("border-width");
const borderColor = document.getElementById("border-color");
const borderConfig = document.getElementById("border-config");
const frameSpacing = document.getElementById("frame-spacing");
const bgColor = document.getElementById("bg-color");
const bgColorGroup = document.getElementById("bg-color-group");
const regeneratePreviewBtn = document.getElementById("regenerate-preview-btn");
const downloadExportBtn = document.getElementById("download-export-btn");
const downloadFramesBtn = document.getElementById("download-frames-btn");
const selCount = document.getElementById("sel-count");
const selCountExport = document.getElementById("sel-count-export");
const frameCountBadge = document.getElementById("frame-count-badge");
const selectAllBtn = document.getElementById("select-all-btn");
const deselectAllBtn = document.getElementById("deselect-all-btn");
const borderToggleText = document.getElementById("border-toggle-text");
let frameSrcs = [];
let currentPreviewUrl = null;
let previewAbortController = null;
let previewTimer = null;
// ── Utility ──
function isSupportedVideo(file) {
if (!file) return false;
if (file.type && file.type.startsWith("video/")) return true;
return /\.(mp4|mov|webm|mkv)$/i.test(file.name);
}
function setVideoFile(file) {
const dt = new DataTransfer();
dt.items.add(file);
videoInput.files = dt.files;
updateDropState();
}
function updateDropState() {
if (!videoInput.files.length) {
dropZone.classList.remove("has-file");
dropLabel.textContent = "Drop your video here, or click to browse";
dropSublabel.textContent = "Supports MP4, MOV, WebM, MKV";
dropIcon.innerHTML = '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>';
return;
}
dropZone.classList.add("has-file");
dropLabel.textContent = videoInput.files[0].name;
dropSublabel.textContent = "Click or drop to change file";
dropIcon.innerHTML = '<path d="M20 6 9 17l-5-5"/>';
}
function showError(msg) {
status.textContent = msg;
status.className = "status-message error";
btn.disabled = false;
}
function showSuccess(msg) {
status.textContent = msg;
status.className = "status-message success";
}
function showInfo(msg, loading) {
if (loading) {
status.innerHTML = '<div class="spinner"></div>' + msg;
} else {
status.textContent = msg;
}
status.className = "status-message";
}
function getFilenameFromPath(src, idx) {
const clean = src.split("?")[0];
const base = clean.substring(clean.lastIndexOf("/") + 1);
return base || `frame_${String(idx + 1).padStart(3, "0")}.jpg`;
}
function getExportConfig() {
const checked = grid.querySelectorAll('input[type="checkbox"]:checked');
const frames = Array.from(checked).map(cb => cb.dataset.src);
return {
frames,
format: exportFormat.value,
add_border: addBorderChk.checked,
border_width: parseInt(borderWidth.value, 10) || 2,
border_color: borderColor.value,
spacing: parseInt(frameSpacing.value, 10) || 0,
background_color: bgColor.value,
quality: parseInt(exportQuality.value, 10) || 90,
};
}
// ── Drop zone ──
dropZone.addEventListener("mousemove", (e) => {
const rect = dropZone.getBoundingClientRect();
dropZone.style.setProperty("--mouse-x", ((e.clientX - rect.left) / rect.width * 100) + "%");
dropZone.style.setProperty("--mouse-y", ((e.clientY - rect.top) / rect.height * 100) + "%");
});
["dragenter", "dragover"].forEach((evt) => {
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add("dragover");
});
});
["dragleave", "dragend", "drop"].forEach((evt) => {
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove("dragover");
});
});
dropZone.addEventListener("drop", (e) => {
const file = e.dataTransfer?.files?.[0];
if (!file) return;
if (!isSupportedVideo(file)) {
showError(`Unsupported file: ${file.name}. Allowed: .mp4, .mov, .webm, .mkv`);
return;
}
setVideoFile(file);
status.textContent = "";
status.className = "status-message";
});
dropZone.addEventListener("click", (e) => {
if (e.target === videoInput) return;
videoInput.click();
});
dropZone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
videoInput.click();
}
});
videoInput.addEventListener("change", () => {
const file = videoInput.files[0];
if (!file) {
updateDropState();
return;
}
if (!isSupportedVideo(file)) {
videoInput.value = "";
updateDropState();
showError(`Unsupported file: ${file.name}. Allowed: .mp4, .mov, .webm, .mkv`);
return;
}
updateDropState();
status.textContent = "";
status.className = "status-message";
});
// ── Select All / Deselect All ──
selectAllBtn.addEventListener("click", () => {
grid.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = true;
cb.closest(".grid-item").classList.add("selected");
});
updateSelectionCount();
});
deselectAllBtn.addEventListener("click", () => {
grid.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = false;
cb.closest(".grid-item").classList.remove("selected");
});
updateSelectionCount();
});
// ── Frame extraction ──
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!videoInput.files.length) { showError("Please select a video file."); return; }
const n = parseInt(nInput.value, 10);
if (!n || n < 1) { showError("Number of frames must be a positive integer."); return; }
const fd = new FormData();
fd.append("video", videoInput.files[0]);
fd.append("n", n);
btn.disabled = true;
showInfo("Uploading and extracting frames\u2026", true);
grid.innerHTML = "";
framesSection.style.display = "none";
exportPanel.style.display = "none";
previewSection.style.display = "none";
frameSrcs = [];
try {
const res = await fetch("/extract", { method: "POST", body: fd });
const data = await res.json();
if (!res.ok) { showError(data.detail || "Unknown error"); return; }
frameSrcs = data.frames;
showSuccess(`Extracted ${data.frames.length} frames. Select frames to export.`);
frameCountBadge.textContent = data.frames.length;
data.frames.forEach((src, i) => {
const item = document.createElement("div");
item.className = "grid-item";
item.style.animationDelay = `${i * 0.04}s`;
const cb = document.createElement("input");
cb.type = "checkbox";
cb.dataset.src = src;
cb.addEventListener("change", () => {
item.classList.toggle("selected", cb.checked);
updateSelectionCount();
});
const img = document.createElement("img");
img.src = src;
img.loading = "lazy";
img.alt = `Frame ${i + 1}`;
const overlay = document.createElement("div");
overlay.className = "select-overlay";
const checkMark = document.createElement("div");
checkMark.className = "check-mark";
checkMark.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const frameIdx = document.createElement("span");
frameIdx.className = "frame-index";
frameIdx.textContent = `#${i + 1}`;
item.addEventListener("click", (e) => {
if (e.target === cb) return;
cb.checked = !cb.checked;
item.classList.toggle("selected", cb.checked);
updateSelectionCount();
});
item.appendChild(cb);
item.appendChild(img);
item.appendChild(overlay);
item.appendChild(checkMark);
item.appendChild(frameIdx);
grid.appendChild(item);
});
framesSection.style.display = "block";
exportPanel.style.display = "block";
updateSelectionCount();
} catch (err) {
showError("Request failed: " + err.message);
} finally {
btn.disabled = false;
}
});
// ── Export config reactivity ──
function onFormatChange() {
const fmt = exportFormat.value;
const isSvg = fmt === "svg";
const isJpg = fmt === "jpg";
svgNote.style.display = isSvg ? "block" : "none";
rasterOptions.style.display = isSvg ? "none" : "block";
qualityGroup.style.display = isJpg ? "block" : "none";
const labels = { png: "Download PNG", jpg: "Download JPG", svg: "Download SVG" };
downloadExportBtn.innerHTML =
'<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>' +
'<polyline points="7 10 12 15 17 10"/>' +
'<line x1="12" y1="15" x2="12" y2="3"/></svg>' +
(labels[fmt] || "Download");
schedulePreview();
}
function onBorderToggle() {
const on = addBorderChk.checked;
borderConfig.style.display = on ? "block" : "none";
borderToggleText.textContent = on ? "Enabled" : "Disabled";
schedulePreview();
}
exportFormat.addEventListener("change", onFormatChange);
addBorderChk.addEventListener("change", onBorderToggle);
exportQuality.addEventListener("input", () => {
qualityValue.textContent = exportQuality.value;
schedulePreview();
});
[borderWidth, borderColor, frameSpacing, bgColor].forEach(el => {
el.addEventListener("input", schedulePreview);
});
// ── Selection ──
function updateSelectionCount() {
const checked = grid.querySelectorAll('input[type="checkbox"]:checked');
const count = checked.length;
const label = `${count} frame${count !== 1 ? "s" : ""} selected`;
selCount.textContent = `${count} selected`;
selCountExport.textContent = label;
downloadExportBtn.disabled = count === 0;
downloadFramesBtn.disabled = count === 0;
schedulePreview();
}
// ── Preview ──
regeneratePreviewBtn.addEventListener("click", () => updatePreview());
function schedulePreview() {
clearTimeout(previewTimer);
previewTimer = setTimeout(updatePreview, 300);
}
async function updatePreview() {
const config = getExportConfig();
if (config.frames.length === 0 || config.format === "svg") {
previewSection.style.display = "none";
if (currentPreviewUrl) {
URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = null;
}
previewImg.src = "";
return;
}
previewSection.style.display = "block";
previewLoading.style.display = "flex";
previewImg.style.opacity = "0.4";
if (previewAbortController) {
previewAbortController.abort();
}
previewAbortController = new AbortController();
try {
const res = await fetch("/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
signal: previewAbortController.signal,
});
if (!res.ok) throw new Error("Failed to generate preview");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
if (currentPreviewUrl) URL.revokeObjectURL(currentPreviewUrl);
currentPreviewUrl = url;
previewImg.src = currentPreviewUrl;
} catch (err) {
if (err.name !== "AbortError") console.error("Preview error:", err);
} finally {
previewLoading.style.display = "none";
previewImg.style.opacity = "1";
}
}
// ── Download export ──
downloadExportBtn.addEventListener("click", async () => {
const config = getExportConfig();
if (config.frames.length === 0) { showError("Please select at least one frame."); return; }
downloadExportBtn.disabled = true;
const origHTML = downloadExportBtn.innerHTML;
downloadExportBtn.innerHTML = '<div class="spinner"></div> Generating\u2026';
try {
const res = await fetch("/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
if (!res.ok) {
const data = await res.json();
showError(data.detail || "Export failed");
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const ext = config.format === "svg" ? "svg" : config.format === "jpg" ? "jpg" : "png";
const filename = config.format === "svg" ? "filmstrip.svg" : `export.${ext}`;
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
showError("Export failed: " + err.message);
} finally {
downloadExportBtn.disabled = false;
downloadExportBtn.innerHTML = origHTML;
updateSelectionCount();
}
});
// ── Download individual frames ──
downloadFramesBtn.addEventListener("click", async () => {
const checked = grid.querySelectorAll('input[type="checkbox"]:checked');
const selected = Array.from(checked).map(cb => cb.dataset.src);
if (selected.length === 0) { showError("Please select at least one image to download."); return; }
downloadFramesBtn.disabled = true;
const origHTML = downloadFramesBtn.innerHTML;
downloadFramesBtn.innerHTML = '<div class="spinner"></div> Downloading\u2026';
showInfo(`Downloading ${selected.length} selected image(s)\u2026`, false);
try {
for (let i = 0; i < selected.length; i++) {
const src = selected[i];
const a = document.createElement("a");
a.href = src;
a.download = getFilenameFromPath(src, i);
document.body.appendChild(a);
a.click();
a.remove();
await new Promise((resolve) => setTimeout(resolve, 80));
}
showSuccess(`Started downloading ${selected.length} selected image(s).`);
} catch (err) {
showError("Download failed: " + err.message);
} finally {
downloadFramesBtn.disabled = false;
downloadFramesBtn.innerHTML = origHTML;
updateSelectionCount();
}
});
// Init format-dependent visibility
onFormatChange();
onBorderToggle();
</script>
</body>
</html>