ASCILINE/asciline_studio.html
2026-06-16 15:06:34 +05:30

1026 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>