Spaces:
Sleeping
Sleeping
| <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… | |
| </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> | |