mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-17 22:35:13 +02:00
1026 lines
32 KiB
HTML
1026 lines
32 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ASCILINE Studio</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #0c0c0e;
|
||
--surface: #141418;
|
||
--raised: #1c1c22;
|
||
--border: #2a2a35;
|
||
--fg: #e2e0ea;
|
||
--muted: #5a5870;
|
||
--green: #00e5a0;
|
||
--green-dim: #00e5a015;
|
||
--green-glow:#00e5a040;
|
||
--amber: #ffb347;
|
||
--red: #ff4d6a;
|
||
--mono: 'IBM Plex Mono', monospace;
|
||
--sans: 'IBM Plex Sans', sans-serif;
|
||
--r: 8px;
|
||
}
|
||
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
font-family: var(--sans);
|
||
font-size: 14px;
|
||
min-height: 100vh;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
/* ── Scanline atmosphere ── */
|
||
body::after {
|
||
content: '';
|
||
position: fixed; inset: 0;
|
||
background: repeating-linear-gradient(0deg, transparent, transparent 2px, #00000008 2px, #00000008 4px);
|
||
pointer-events: none;
|
||
z-index: 9999;
|
||
}
|
||
|
||
/* ══════════════════════════════════════
|
||
SCREEN SYSTEM
|
||
══════════════════════════════════════ */
|
||
.screen { display: none; flex-direction: column; min-height: 100vh; }
|
||
.screen.active { display: flex; }
|
||
|
||
/* ══════════════════════════════════════
|
||
SCREEN 1 — UPLOAD
|
||
══════════════════════════════════════ */
|
||
#screen-upload {
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 32px 20px;
|
||
gap: 32px;
|
||
}
|
||
|
||
.brand {
|
||
text-align: center;
|
||
}
|
||
.brand-logo {
|
||
font-family: var(--mono);
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
color: var(--green);
|
||
letter-spacing: 2px;
|
||
}
|
||
.brand-logo span { color: var(--muted); font-weight: 400; }
|
||
.brand-sub {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
/* Drop zone */
|
||
.dropzone {
|
||
width: 100%;
|
||
max-width: 520px;
|
||
border: 2px dashed var(--border);
|
||
border-radius: 16px;
|
||
padding: 56px 32px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s, background 0.2s;
|
||
background: var(--surface);
|
||
position: relative;
|
||
}
|
||
.dropzone:hover, .dropzone.over {
|
||
border-color: var(--green);
|
||
background: var(--green-dim);
|
||
}
|
||
.dropzone input { display: none; }
|
||
.dz-icon {
|
||
font-size: 40px;
|
||
margin-bottom: 16px;
|
||
display: block;
|
||
filter: grayscale(0.5);
|
||
}
|
||
.dz-title {
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
}
|
||
.dz-sub {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
}
|
||
.dz-formats {
|
||
margin-top: 14px;
|
||
display: flex;
|
||
gap: 6px;
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.fmt-tag {
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
background: var(--raised);
|
||
color: var(--muted);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
/* ── Quality picker ── */
|
||
.quality-section {
|
||
width: 100%;
|
||
max-width: 520px;
|
||
}
|
||
.section-label {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
color: var(--muted);
|
||
margin-bottom: 10px;
|
||
font-family: var(--mono);
|
||
}
|
||
.quality-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.q-card {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r);
|
||
padding: 14px 10px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
background: var(--surface);
|
||
transition: all 0.18s;
|
||
user-select: none;
|
||
}
|
||
.q-card:hover { border-color: var(--green); background: var(--green-dim); }
|
||
.q-card.selected {
|
||
border-color: var(--green);
|
||
background: var(--green-dim);
|
||
box-shadow: 0 0 0 1px var(--green);
|
||
}
|
||
.q-emoji { font-size: 22px; display: block; margin-bottom: 6px; }
|
||
.q-name { font-weight: 600; font-size: 13px; }
|
||
.q-detail { font-size: 11px; color: var(--muted); margin-top: 3px; font-family: var(--mono); }
|
||
|
||
/* Render button */
|
||
.render-btn {
|
||
width: 100%;
|
||
max-width: 520px;
|
||
padding: 15px;
|
||
background: var(--green);
|
||
color: #000;
|
||
border: none;
|
||
border-radius: var(--r);
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
font-family: var(--sans);
|
||
cursor: pointer;
|
||
transition: all 0.18s;
|
||
letter-spacing: 0.3px;
|
||
opacity: 0.4;
|
||
pointer-events: none;
|
||
}
|
||
.render-btn.ready { opacity: 1; pointer-events: all; }
|
||
.render-btn.ready:hover { background: #00ffb3; transform: translateY(-1px); }
|
||
|
||
/* ══════════════════════════════════════
|
||
SCREEN 2 — RENDERING PROGRESS
|
||
══════════════════════════════════════ */
|
||
#screen-render {
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px 20px;
|
||
gap: 28px;
|
||
}
|
||
|
||
.render-title {
|
||
font-family: var(--mono);
|
||
font-size: 20px;
|
||
color: var(--green);
|
||
text-align: center;
|
||
}
|
||
.render-file {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
text-align: center;
|
||
font-family: var(--mono);
|
||
margin-top: -16px;
|
||
}
|
||
|
||
.progress-card {
|
||
width: 100%;
|
||
max-width: 560px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.prog-bar-wrap {
|
||
height: 6px;
|
||
background: var(--raised);
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
.prog-bar-fill {
|
||
height: 100%;
|
||
background: var(--green);
|
||
border-radius: 3px;
|
||
width: 0%;
|
||
transition: width 0.3s ease;
|
||
box-shadow: 0 0 8px var(--green-glow);
|
||
}
|
||
|
||
.prog-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 12px;
|
||
}
|
||
.prog-stat { text-align: center; }
|
||
.prog-stat .val {
|
||
font-family: var(--mono);
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: var(--fg);
|
||
}
|
||
.prog-stat .lbl {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.8px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.prog-status {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
text-align: center;
|
||
min-height: 18px;
|
||
}
|
||
|
||
/* Live preview strip */
|
||
.preview-strip {
|
||
width: 100%;
|
||
max-width: 560px;
|
||
background: #000;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r);
|
||
overflow: hidden;
|
||
display: none;
|
||
position: relative;
|
||
}
|
||
.preview-strip canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
image-rendering: pixelated;
|
||
}
|
||
.preview-label {
|
||
position: absolute;
|
||
top: 8px; left: 10px;
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
color: var(--green);
|
||
background: #000a;
|
||
padding: 2px 7px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* ══════════════════════════════════════
|
||
SCREEN 3 — PLAYER
|
||
══════════════════════════════════════ */
|
||
#screen-player {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.player-topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 16px;
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
.player-logo {
|
||
font-family: var(--mono);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--green);
|
||
}
|
||
.player-file {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.spacer { flex: 1; }
|
||
.topbar-badge {
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
padding: 2px 8px;
|
||
border-radius: 20px;
|
||
background: var(--green-dim);
|
||
color: var(--green);
|
||
border: 1px solid var(--green);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.back-btn {
|
||
font-size: 12px;
|
||
font-family: var(--mono);
|
||
color: var(--muted);
|
||
background: none;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 5px 12px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.back-btn:hover { color: var(--fg); border-color: var(--muted); }
|
||
|
||
/* Canvas viewport — fills available space */
|
||
.player-viewport {
|
||
flex: 1;
|
||
background: #000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
position: relative;
|
||
min-height: 0;
|
||
}
|
||
.player-viewport canvas {
|
||
display: block;
|
||
image-rendering: pixelated;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
}
|
||
.frame-badge {
|
||
position: absolute;
|
||
top: 10px; right: 12px;
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
color: var(--green);
|
||
background: #000b;
|
||
padding: 3px 9px;
|
||
border-radius: 5px;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
}
|
||
.player-viewport:hover .frame-badge { opacity: 1; }
|
||
|
||
/* Controls bar */
|
||
.player-controls {
|
||
background: var(--surface);
|
||
border-top: 1px solid var(--border);
|
||
padding: 10px 16px 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.seek-bar {
|
||
width: 100%;
|
||
height: 4px;
|
||
background: var(--raised);
|
||
border-radius: 2px;
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
.seek-fill {
|
||
height: 100%;
|
||
background: var(--green);
|
||
border-radius: 2px;
|
||
width: 0%;
|
||
pointer-events: none;
|
||
}
|
||
.seek-bar:hover { height: 6px; margin-top: -1px; }
|
||
|
||
.ctrl-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.cbtn {
|
||
width: 34px; height: 34px;
|
||
background: var(--raised);
|
||
border: 1px solid var(--border);
|
||
border-radius: 7px;
|
||
color: var(--fg);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.cbtn:hover { border-color: var(--green); color: var(--green); }
|
||
.cbtn.play-btn {
|
||
width: 40px; height: 40px;
|
||
background: var(--green);
|
||
color: #000;
|
||
border-color: var(--green);
|
||
font-size: 15px;
|
||
}
|
||
.cbtn.play-btn:hover { background: #00ffb3; }
|
||
|
||
.time-display {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
min-width: 90px;
|
||
}
|
||
|
||
.ctrl-select {
|
||
background: var(--raised);
|
||
color: var(--fg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 5px 8px;
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
outline: none;
|
||
}
|
||
.ctrl-select:focus { border-color: var(--green); }
|
||
|
||
.stats-row {
|
||
display: flex;
|
||
gap: 16px;
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
}
|
||
.stats-row span { display: flex; align-items: center; gap: 4px; }
|
||
.stats-row b { color: var(--fg); font-weight: 600; }
|
||
|
||
/* ══════════════════════════════════════
|
||
TOAST
|
||
══════════════════════════════════════ */
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 24px; left: 50%;
|
||
transform: translateX(-50%) translateY(80px);
|
||
background: var(--raised);
|
||
border: 1px solid var(--border);
|
||
color: var(--fg);
|
||
padding: 10px 20px;
|
||
border-radius: var(--r);
|
||
font-size: 13px;
|
||
font-family: var(--mono);
|
||
opacity: 0;
|
||
transition: all 0.3s;
|
||
z-index: 1000;
|
||
pointer-events: none;
|
||
white-space: nowrap;
|
||
}
|
||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||
.toast.ok { border-color: var(--green); color: var(--green); }
|
||
.toast.err { border-color: var(--red); color: var(--red); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ══ SCREEN 1: UPLOAD ══════════════════════════════════════════════ -->
|
||
<div class="screen active" id="screen-upload">
|
||
<div class="brand">
|
||
<div class="brand-logo">ASCILINE<span>.studio</span></div>
|
||
<div class="brand-sub">Video → Pixel Art Renderer</div>
|
||
</div>
|
||
|
||
<div class="dropzone" id="dropzone">
|
||
<input type="file" id="fileInput" accept="video/*,.mp4,.mov,.avi,.mkv,.webm">
|
||
<span class="dz-icon">🎬</span>
|
||
<div class="dz-title">Drop your video here</div>
|
||
<div class="dz-sub">or click to browse files</div>
|
||
<div class="dz-formats">
|
||
<span class="fmt-tag">MP4</span>
|
||
<span class="fmt-tag">MOV</span>
|
||
<span class="fmt-tag">AVI</span>
|
||
<span class="fmt-tag">MKV</span>
|
||
<span class="fmt-tag">WEBM</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="quality-section">
|
||
<div class="section-label">Pick quality</div>
|
||
<div class="quality-grid">
|
||
<div class="q-card" data-cols="320" data-fps="8">
|
||
<span class="q-emoji">🚀</span>
|
||
<div class="q-name">Fast</div>
|
||
<div class="q-detail">320 cols · 8fps</div>
|
||
</div>
|
||
<div class="q-card selected" data-cols="480" data-fps="12">
|
||
<span class="q-emoji">⚡</span>
|
||
<div class="q-name">Balanced</div>
|
||
<div class="q-detail">480 cols · 12fps</div>
|
||
</div>
|
||
<div class="q-card" data-cols="640" data-fps="12">
|
||
<span class="q-emoji">🎯</span>
|
||
<div class="q-name">Sharp</div>
|
||
<div class="q-detail">640 cols · 12fps</div>
|
||
</div>
|
||
<div class="q-card" data-cols="960" data-fps="15">
|
||
<span class="q-emoji">💎</span>
|
||
<div class="q-name">Ultra</div>
|
||
<div class="q-detail">960 cols · 15fps</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="render-btn" id="renderBtn">Choose a video to start →</button>
|
||
</div>
|
||
|
||
<!-- ══ SCREEN 2: RENDERING ═══════════════════════════════════════════ -->
|
||
<div class="screen" id="screen-render">
|
||
<div class="render-title">Rendering…</div>
|
||
<div class="render-file" id="renderFileName">—</div>
|
||
|
||
<div class="progress-card">
|
||
<div class="prog-bar-wrap">
|
||
<div class="prog-bar-fill" id="progBar"></div>
|
||
</div>
|
||
<div class="prog-stats">
|
||
<div class="prog-stat">
|
||
<div class="val" id="progFrames">0</div>
|
||
<div class="lbl">Frames</div>
|
||
</div>
|
||
<div class="prog-stat">
|
||
<div class="val" id="progPct">0%</div>
|
||
<div class="lbl">Complete</div>
|
||
</div>
|
||
<div class="prog-stat">
|
||
<div class="val" id="progFps">—</div>
|
||
<div class="lbl">Speed</div>
|
||
</div>
|
||
</div>
|
||
<div class="prog-status" id="progStatus">Initialising…</div>
|
||
</div>
|
||
|
||
<div class="preview-strip" id="previewStrip">
|
||
<canvas id="previewCanvas"></canvas>
|
||
<div class="preview-label">LIVE PREVIEW</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ SCREEN 3: PLAYER ══════════════════════════════════════════════ -->
|
||
<div class="screen" id="screen-player">
|
||
<div class="player-topbar">
|
||
<div class="player-logo">ASCILINE</div>
|
||
<div class="player-file" id="playerFileName">—</div>
|
||
<div class="spacer"></div>
|
||
<div class="topbar-badge" id="qualityBadge">—</div>
|
||
<button class="back-btn" id="backBtn">← New video</button>
|
||
</div>
|
||
|
||
<div class="player-viewport" id="playerViewport">
|
||
<canvas id="playerCanvas"></canvas>
|
||
<div class="frame-badge" id="frameBadge">0 / 0</div>
|
||
</div>
|
||
|
||
<div class="player-controls">
|
||
<div class="seek-bar" id="seekBar">
|
||
<div class="seek-fill" id="seekFill"></div>
|
||
</div>
|
||
<div class="ctrl-row">
|
||
<button class="cbtn" id="btnFirst" title="First frame">⏮</button>
|
||
<button class="cbtn" id="btnPrev" title="Previous">⏪</button>
|
||
<button class="cbtn play-btn" id="btnPlay" title="Play / Pause">▶</button>
|
||
<button class="cbtn" id="btnNext" title="Next">⏩</button>
|
||
<button class="cbtn" id="btnLast" title="Last frame">⏭</button>
|
||
<div class="time-display" id="timeDisplay">0:00 / 0:00</div>
|
||
<div class="spacer"></div>
|
||
<label style="font-size:11px;color:var(--muted);font-family:var(--mono)">Speed</label>
|
||
<select class="ctrl-select" id="speedSel">
|
||
<option value="0.25">0.25×</option>
|
||
<option value="0.5">0.5×</option>
|
||
<option value="1" selected>1×</option>
|
||
<option value="1.5">1.5×</option>
|
||
<option value="2">2×</option>
|
||
<option value="3">3×</option>
|
||
</select>
|
||
</div>
|
||
<div class="stats-row" id="statsRow">
|
||
<span>Grid <b id="sGrid">—</b></span>
|
||
<span>FPS <b id="sFps">—</b></span>
|
||
<span>Frames <b id="sFrames">—</b></span>
|
||
<span>Duration <b id="sDur">—</b></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
ASCILINE STUDIO — Single-file Video → Pixel Art Player
|
||
Renders video frames directly in the browser using Canvas API.
|
||
No server, no Python, no external files needed.
|
||
═══════════════════════════════════════════════════════════════════ */
|
||
|
||
// ── Screen router ─────────────────────────────────────────────────
|
||
function show(id) {
|
||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||
document.getElementById(id).classList.add('active');
|
||
}
|
||
|
||
// ── Toast ─────────────────────────────────────────────────────────
|
||
let _toastT = null;
|
||
function toast(msg, type = '') {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.className = 'toast show' + (type ? ' ' + type : '');
|
||
clearTimeout(_toastT);
|
||
_toastT = setTimeout(() => el.classList.remove('show'), 2800);
|
||
}
|
||
|
||
// ── Upload screen ─────────────────────────────────────────────────
|
||
const dropzone = document.getElementById('dropzone');
|
||
const fileInput = document.getElementById('fileInput');
|
||
const renderBtn = document.getElementById('renderBtn');
|
||
|
||
let selectedFile = null;
|
||
let selectedCols = 480;
|
||
let selectedFps = 12;
|
||
|
||
// Quality cards
|
||
document.querySelectorAll('.q-card').forEach(card => {
|
||
card.addEventListener('click', () => {
|
||
document.querySelectorAll('.q-card').forEach(c => c.classList.remove('selected'));
|
||
card.classList.add('selected');
|
||
selectedCols = parseInt(card.dataset.cols);
|
||
selectedFps = parseInt(card.dataset.fps);
|
||
});
|
||
});
|
||
|
||
dropzone.addEventListener('click', () => fileInput.click());
|
||
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('over'); });
|
||
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('over'));
|
||
dropzone.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
dropzone.classList.remove('over');
|
||
const f = e.dataTransfer.files[0];
|
||
if (f && f.type.startsWith('video/')) setFile(f);
|
||
else toast('Please drop a video file', 'err');
|
||
});
|
||
fileInput.addEventListener('change', e => {
|
||
const f = e.target.files[0];
|
||
if (f) setFile(f);
|
||
});
|
||
|
||
function setFile(f) {
|
||
selectedFile = f;
|
||
dropzone.querySelector('.dz-title').textContent = f.name;
|
||
dropzone.querySelector('.dz-sub').textContent = formatBytes(f.size);
|
||
renderBtn.textContent = 'Render video →';
|
||
renderBtn.classList.add('ready');
|
||
}
|
||
|
||
renderBtn.addEventListener('click', () => {
|
||
if (!selectedFile) return;
|
||
startRender(selectedFile, selectedCols, selectedFps);
|
||
});
|
||
|
||
// ── Back button ───────────────────────────────────────────────────
|
||
document.getElementById('backBtn').addEventListener('click', () => {
|
||
stopPlayer();
|
||
frames = [];
|
||
show('screen-upload');
|
||
fileInput.value = '';
|
||
selectedFile = null;
|
||
renderBtn.textContent = 'Choose a video to start →';
|
||
renderBtn.classList.remove('ready');
|
||
dropzone.querySelector('.dz-title').textContent = 'Drop your video here';
|
||
dropzone.querySelector('.dz-sub').textContent = 'or click to browse files';
|
||
});
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
RENDERER — Browser-native video → pixel frames
|
||
Uses <video> + <canvas> to decode frames, no Python needed.
|
||
═══════════════════════════════════════════════════════════════════ */
|
||
let frames = []; // Array of Uint8Array (RGB flat)
|
||
let metaInfo = {};
|
||
|
||
async function startRender(file, cols, fps) {
|
||
frames = [];
|
||
metaInfo = {};
|
||
show('screen-render');
|
||
|
||
document.getElementById('renderFileName').textContent = file.name;
|
||
document.getElementById('progBar').style.width = '0%';
|
||
document.getElementById('progFrames').textContent = '0';
|
||
document.getElementById('progPct').textContent = '0%';
|
||
document.getElementById('progFps').textContent = '—';
|
||
document.getElementById('progStatus').textContent = 'Loading video…';
|
||
document.getElementById('previewStrip').style.display = 'none';
|
||
|
||
// Create hidden video element
|
||
const video = document.createElement('video');
|
||
video.muted = true;
|
||
video.playsInline = true;
|
||
video.style.display = 'none';
|
||
document.body.appendChild(video);
|
||
|
||
const url = URL.createObjectURL(file);
|
||
video.src = url;
|
||
|
||
await new Promise((res, rej) => {
|
||
video.onloadedmetadata = res;
|
||
video.onerror = () => rej(new Error('Could not load video'));
|
||
setTimeout(() => rej(new Error('Video load timeout')), 15000);
|
||
});
|
||
|
||
const vidW = video.videoWidth;
|
||
const vidH = video.videoHeight;
|
||
const vidFps = await detectFps(video) || 30;
|
||
const duration = video.duration;
|
||
|
||
// Calculate rows maintaining aspect ratio
|
||
const rows = Math.max(1, Math.round(cols / (vidW / vidH)));
|
||
|
||
// Off-screen canvas for frame extraction
|
||
const offCanvas = new OffscreenCanvas(cols, rows);
|
||
const offCtx = offCanvas.getContext('2d', { willReadFrequently: true });
|
||
|
||
// Preview canvas
|
||
const previewCanvas = document.getElementById('previewCanvas');
|
||
const previewCtx = previewCanvas.getContext('2d');
|
||
previewCanvas.width = cols;
|
||
previewCanvas.height = rows;
|
||
document.getElementById('previewStrip').style.display = 'block';
|
||
|
||
metaInfo = { cols, rows, fps, mode: 'pixel', vidW, vidH };
|
||
|
||
document.getElementById('progStatus').textContent = `${cols}×${rows} grid · ${fps} fps cap · ${Math.round(duration)}s video`;
|
||
|
||
const skipEvery = Math.max(1, Math.round(vidFps / fps));
|
||
const totalFrames = Math.floor(duration * vidFps / skipEvery);
|
||
|
||
let frameIdx = 0;
|
||
let rendered = 0;
|
||
let lastTime = performance.now();
|
||
let frameTime = 0;
|
||
|
||
// ── Seek-based frame extraction ───────────────────────────────
|
||
// More reliable than play() for large files; works offline
|
||
const frameInterval = 1 / fps;
|
||
let t = 0;
|
||
|
||
while (t < duration - frameInterval * 0.5) {
|
||
// Seek video to timestamp
|
||
await seekVideo(video, t);
|
||
|
||
// Draw frame to off-screen canvas
|
||
offCtx.drawImage(video, 0, 0, cols, rows);
|
||
const imgData = offCtx.getImageData(0, 0, cols, rows);
|
||
const rgba = imgData.data; // Uint8ClampedArray RGBA
|
||
|
||
// Convert RGBA → RGB Uint8Array
|
||
const rgb = new Uint8Array(cols * rows * 3);
|
||
for (let i = 0; i < cols * rows; i++) {
|
||
rgb[i * 3] = rgba[i * 4];
|
||
rgb[i * 3 + 1] = rgba[i * 4 + 1];
|
||
rgb[i * 3 + 2] = rgba[i * 4 + 2];
|
||
}
|
||
frames.push(rgb);
|
||
|
||
// Live preview: copy to preview canvas
|
||
previewCtx.putImageData(imgData, 0, 0);
|
||
|
||
rendered++;
|
||
t += frameInterval;
|
||
|
||
// Update progress every 3 frames
|
||
if (rendered % 3 === 0 || t >= duration) {
|
||
const pct = Math.min(100, Math.round(t / duration * 100));
|
||
const now = performance.now();
|
||
const elapsed = (now - lastTime) / 1000;
|
||
const rps = elapsed > 0 ? Math.round(3 / elapsed) : 0;
|
||
lastTime = now;
|
||
|
||
document.getElementById('progBar').style.width = pct + '%';
|
||
document.getElementById('progFrames').textContent = rendered;
|
||
document.getElementById('progPct').textContent = pct + '%';
|
||
document.getElementById('progFps').textContent = rps + ' f/s';
|
||
document.getElementById('progStatus').textContent = `Frame ${rendered} · ${fmtTime(t)} / ${fmtTime(duration)}`;
|
||
|
||
// Yield to keep UI responsive
|
||
await new Promise(r => setTimeout(r, 0));
|
||
}
|
||
}
|
||
|
||
video.src = '';
|
||
URL.revokeObjectURL(url);
|
||
document.body.removeChild(video);
|
||
|
||
document.getElementById('progStatus').textContent = `Done — ${frames.length} frames`;
|
||
document.getElementById('progPct').textContent = '100%';
|
||
document.getElementById('progBar').style.width = '100%';
|
||
|
||
await new Promise(r => setTimeout(r, 600));
|
||
initPlayer();
|
||
}
|
||
|
||
function seekVideo(video, t) {
|
||
return new Promise(res => {
|
||
const onSeeked = () => { video.removeEventListener('seeked', onSeeked); res(); };
|
||
video.addEventListener('seeked', onSeeked);
|
||
video.currentTime = t;
|
||
});
|
||
}
|
||
|
||
async function detectFps(video) {
|
||
// Try to get FPS from video duration vs frame count heuristic
|
||
// Most browsers expose this via requestVideoFrameCallback if available
|
||
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
|
||
return new Promise(res => {
|
||
let t0, f0;
|
||
let count = 0;
|
||
const cb = (now, meta) => {
|
||
if (count === 0) { t0 = meta.mediaTime; f0 = now; }
|
||
count++;
|
||
if (count < 5) { video.requestVideoFrameCallback(cb); }
|
||
else { res(Math.round(count / (meta.mediaTime - t0))); }
|
||
};
|
||
video.currentTime = 0;
|
||
video.requestVideoFrameCallback(cb);
|
||
video.play().catch(() => {});
|
||
setTimeout(() => { video.pause(); res(30); }, 3000);
|
||
});
|
||
}
|
||
return 30; // safe fallback
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
PLAYER
|
||
═══════════════════════════════════════════════════════════════════ */
|
||
const playerCanvas = document.getElementById('playerCanvas');
|
||
const playerCtx = playerCanvas.getContext('2d');
|
||
const playerViewport = document.getElementById('playerViewport');
|
||
|
||
let playing = false;
|
||
let currentFrame = 0;
|
||
let animId = null;
|
||
let lastTick = 0;
|
||
|
||
function initPlayer() {
|
||
show('screen-player');
|
||
|
||
const { cols, rows, fps, vidW, vidH } = metaInfo;
|
||
|
||
// Set canvas native resolution
|
||
playerCanvas.width = cols;
|
||
playerCanvas.height = rows;
|
||
|
||
// Fit canvas to viewport maintaining aspect ratio
|
||
fitCanvas();
|
||
window.addEventListener('resize', fitCanvas);
|
||
|
||
// Update UI labels
|
||
document.getElementById('playerFileName').textContent = selectedFile?.name || '';
|
||
document.getElementById('qualityBadge').textContent = `${cols}×${rows}`;
|
||
document.getElementById('sGrid').textContent = `${cols}×${rows}`;
|
||
document.getElementById('sFps').textContent = fps;
|
||
document.getElementById('sFrames').textContent = frames.length;
|
||
document.getElementById('sDur').textContent = fmtTime(frames.length / fps);
|
||
|
||
currentFrame = 0;
|
||
renderFrame(0);
|
||
updateSeek();
|
||
startPlayer();
|
||
}
|
||
|
||
function fitCanvas() {
|
||
const { cols, rows } = metaInfo;
|
||
if (!cols || !rows) return;
|
||
const vw = playerViewport.clientWidth;
|
||
const vh = playerViewport.clientHeight;
|
||
const scale = Math.min(vw / cols, vh / rows);
|
||
playerCanvas.style.width = Math.floor(cols * scale) + 'px';
|
||
playerCanvas.style.height = Math.floor(rows * scale) + 'px';
|
||
}
|
||
|
||
function renderFrame(idx) {
|
||
if (!frames.length || idx < 0 || idx >= frames.length) return;
|
||
const { cols, rows } = metaInfo;
|
||
const frame = frames[idx];
|
||
const imgData = playerCtx.createImageData(cols, rows);
|
||
const px = imgData.data;
|
||
const total = cols * rows;
|
||
|
||
for (let i = 0; i < total; i++) {
|
||
const s = i * 3, d = i * 4;
|
||
px[d] = frame[s];
|
||
px[d + 1] = frame[s + 1];
|
||
px[d + 2] = frame[s + 2];
|
||
px[d + 3] = 255;
|
||
}
|
||
playerCtx.putImageData(imgData, 0, 0);
|
||
document.getElementById('frameBadge').textContent = `${idx + 1} / ${frames.length}`;
|
||
updateTime(idx);
|
||
}
|
||
|
||
function updateSeek() {
|
||
const ratio = frames.length > 1 ? currentFrame / (frames.length - 1) : 0;
|
||
document.getElementById('seekFill').style.width = (ratio * 100) + '%';
|
||
}
|
||
|
||
function updateTime(idx) {
|
||
const fps = metaInfo.fps || 12;
|
||
const cur = idx / fps;
|
||
const tot = frames.length / fps;
|
||
document.getElementById('timeDisplay').textContent = `${fmtTime(cur)} / ${fmtTime(tot)}`;
|
||
}
|
||
|
||
function startPlayer() {
|
||
if (playing) return;
|
||
playing = true;
|
||
document.getElementById('btnPlay').textContent = '⏸';
|
||
lastTick = performance.now();
|
||
tick();
|
||
}
|
||
|
||
function stopPlayer() {
|
||
playing = false;
|
||
document.getElementById('btnPlay').textContent = '▶';
|
||
if (animId) { cancelAnimationFrame(animId); animId = null; }
|
||
}
|
||
|
||
function togglePlayer() { playing ? stopPlayer() : startPlayer(); }
|
||
|
||
function tick() {
|
||
if (!playing) return;
|
||
const fps = (metaInfo.fps || 12) * parseFloat(document.getElementById('speedSel').value || 1);
|
||
const now = performance.now();
|
||
const needed = 1000 / fps;
|
||
|
||
if (now - lastTick >= needed) {
|
||
lastTick = now;
|
||
currentFrame++;
|
||
if (currentFrame >= frames.length) {
|
||
currentFrame = 0; // loop
|
||
}
|
||
renderFrame(currentFrame);
|
||
updateSeek();
|
||
}
|
||
animId = requestAnimationFrame(tick);
|
||
}
|
||
|
||
// Controls
|
||
document.getElementById('btnPlay').addEventListener('click', togglePlayer);
|
||
document.getElementById('btnFirst').addEventListener('click', () => { stopPlayer(); currentFrame = 0; renderFrame(0); updateSeek(); });
|
||
document.getElementById('btnLast').addEventListener('click', () => { stopPlayer(); currentFrame = frames.length - 1; renderFrame(currentFrame); updateSeek(); });
|
||
document.getElementById('btnNext').addEventListener('click', () => { stopPlayer(); currentFrame = Math.min(currentFrame + 1, frames.length - 1); renderFrame(currentFrame); updateSeek(); });
|
||
document.getElementById('btnPrev').addEventListener('click', () => { stopPlayer(); currentFrame = Math.max(currentFrame - 1, 0); renderFrame(currentFrame); updateSeek(); });
|
||
|
||
// Seek bar
|
||
const seekBar = document.getElementById('seekBar');
|
||
let seeking = false;
|
||
function seekFromEvent(e) {
|
||
const rect = seekBar.getBoundingClientRect();
|
||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||
currentFrame = Math.round(ratio * (frames.length - 1));
|
||
renderFrame(currentFrame);
|
||
updateSeek();
|
||
}
|
||
seekBar.addEventListener('mousedown', e => { seeking = true; stopPlayer(); seekFromEvent(e); });
|
||
window.addEventListener('mousemove', e => { if (seeking) seekFromEvent(e); });
|
||
window.addEventListener('mouseup', () => { seeking = false; });
|
||
|
||
// Keyboard
|
||
document.addEventListener('keydown', e => {
|
||
const active = document.querySelector('.screen.active')?.id;
|
||
if (active !== 'screen-player') return;
|
||
switch (e.code) {
|
||
case 'Space': e.preventDefault(); togglePlayer(); break;
|
||
case 'ArrowRight': e.preventDefault(); document.getElementById('btnNext').click(); break;
|
||
case 'ArrowLeft': e.preventDefault(); document.getElementById('btnPrev').click(); break;
|
||
case 'Home': e.preventDefault(); document.getElementById('btnFirst').click(); break;
|
||
case 'End': e.preventDefault(); document.getElementById('btnLast').click(); break;
|
||
}
|
||
});
|
||
|
||
// ── Utilities ─────────────────────────────────────────────────────
|
||
function fmtTime(s) {
|
||
const m = Math.floor(s / 60);
|
||
const sec = Math.floor(s % 60);
|
||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||
}
|
||
function formatBytes(b) {
|
||
if (b < 1024) return b + ' B';
|
||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||
return (b / 1048576).toFixed(1) + ' MB';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|